From 7e12a2401715abdae9ac696c81f549c81fabe08e Mon Sep 17 00:00:00 2001 From: Iurii Makhno Date: Tue, 29 Jun 2021 16:06:43 +0100 Subject: [PATCH] Prepare for release 1.7,0. --- README.md | 7 +- build.gradle | 25 +- gradle.properties | 2 +- .../build/bundletool/BundleToolMain.java | 14 + .../commands/AddTransparencyCommand.java | 375 ++++++++- .../commands/ApkTransparencyChecker.java | 116 --- .../bundletool/commands/BuildApksManager.java | 10 +- .../commands/BundleTransparencyChecker.java | 75 -- .../commands/CheckTransparencyCommand.java | 202 ++++- .../ConnectedDeviceTransparencyChecker.java | 28 - .../commands/ExtractApksCommand.java | 25 +- .../commands/GetDeviceSpecCommand.java | 26 +- .../commands/InstallApksCommand.java | 28 +- .../build/bundletool/device/ApkMatcher.java | 7 +- .../build/bundletool/device/DdmlibDevice.java | 31 +- .../tools/build/bundletool/device/Device.java | 28 + ...her.java => DeviceGroupModuleMatcher.java} | 26 +- .../device/LocalTestingPathResolver.java | 43 + .../bundletool/device/ModuleMatcher.java | 9 +- .../io/ApkzlibApkSerializerHelper.java | 35 +- .../build/bundletool/io/BytesSource2.java | 52 ++ .../io/ZipFlingerApkSerializerHelper.java | 204 +++-- .../build/bundletool/mergers/D8DexMerger.java | 114 ++- .../bundletool/model/AndroidManifest.java | 7 +- .../build/bundletool/model/AppBundle.java | 50 +- .../build/bundletool/model/BundleModule.java | 59 +- ...dition.java => DeviceGroupsCondition.java} | 10 +- .../model/ManifestDeliveryElement.java | 42 +- .../bundletool/model/ModuleConditions.java | 14 +- .../build/bundletool/model/ModuleSplit.java | 1 + .../tools/build/bundletool/model/ZipPath.java | 3 +- .../targeting/TargetedDirectorySegment.java | 4 +- ...erUtils.java => DeviceTargetingUtils.java} | 21 +- .../model/version/BundleToolVersion.java | 2 +- .../model/version/VersionGuardedFeature.java | 26 +- .../optimizations/OptimizationsMerger.java | 5 +- .../shards/StandaloneApksGenerator.java | 6 +- .../shards/SystemApksGenerator.java | 6 +- .../splitters/CodeTransparencyInjector.java | 21 +- .../bundletool/splitters/ModuleSplitter.java | 19 +- .../splitters/SplitApksGenerator.java | 10 +- .../ApkModeTransparencyChecker.java | 79 ++ .../transparency/ApkSignatureVerifier.java | 109 +++ .../ApkTransparencyCheckUtils.java | 200 +++++ .../BundleModeTransparencyChecker.java | 44 + .../BundleTransparencyCheckUtils.java | 126 +++ .../transparency/CodeTransparencyChecker.java | 137 ---- .../CodeTransparencyCryptoUtils.java | 139 ++++ .../transparency/CodeTransparencyFactory.java | 42 +- ...onnectedDeviceModeTransparencyChecker.java | 108 +++ .../transparency/TransparencyCheckResult.java | 108 +-- .../validation/CodeTransparencyValidator.java | 14 +- .../validation/ModuleNamesValidator.java | 4 +- src/main/proto/commands.proto | 18 + src/main/proto/devices.proto | 3 + src/main/proto/rotation_config.proto | 16 + src/main/proto/targeting.proto | 8 +- .../commands/AddTransparencyCommandTest.java | 571 ++++++++++++- .../commands/ApkTransparencyCheckerTest.java | 284 ------- .../commands/BuildApksCommandTest.java | 4 +- .../commands/BuildApksManagerTest.java | 176 +++- .../CheckTransparencyCommandTest.java | 775 +++++++++++++----- .../commands/ExtractApksCommandTest.java | 93 ++- .../commands/GetDeviceSpecCommandTest.java | 8 +- .../bundletool/device/ApkMatcherTest.java | 10 +- .../bundletool/device/DdmlibDeviceTest.java | 16 + ...java => DeviceGroupModuleMatcherTest.java} | 18 +- .../device/LocalTestingPathResolverTest.java | 51 ++ .../bundletool/device/ModuleMatcherTest.java | 12 +- .../bundletool/mergers/D8DexMergerTest.java | 36 +- .../bundletool/model/AndroidManifestTest.java | 11 + .../build/bundletool/model/AppBundleTest.java | 19 +- .../bundletool/model/BundleModuleTest.java | 13 +- .../model/ManifestDeliveryElementTest.java | 62 +- .../model/ModuleConditionsTest.java | 13 +- .../shards/StandaloneApksGeneratorTest.java | 28 +- .../CodeTransparencyInjectorTest.java | 194 +++++ .../splitters/ModuleSplitterTest.java | 55 +- .../testing/BundleModuleBuilder.java | 19 + .../testing/CodeTransparencyTestUtils.java | 50 ++ .../bundletool/testing/DeviceFactory.java | 4 + .../build/bundletool/testing/FakeDevice.java | 17 + .../testing/ManifestProtoUtils.java | 32 +- .../bundletool/testing/TargetingUtils.java | 8 +- .../CodeTransparencyValidatorTest.java | 6 +- .../dex/classes-emulated-coredesugar.dex | Bin 0 -> 1172 bytes 86 files changed, 4094 insertions(+), 1434 deletions(-) delete mode 100644 src/main/java/com/android/tools/build/bundletool/commands/ApkTransparencyChecker.java delete mode 100644 src/main/java/com/android/tools/build/bundletool/commands/BundleTransparencyChecker.java delete mode 100644 src/main/java/com/android/tools/build/bundletool/commands/ConnectedDeviceTransparencyChecker.java rename src/main/java/com/android/tools/build/bundletool/device/{DeviceTierModuleMatcher.java => DeviceGroupModuleMatcher.java} (53%) create mode 100644 src/main/java/com/android/tools/build/bundletool/device/LocalTestingPathResolver.java create mode 100644 src/main/java/com/android/tools/build/bundletool/io/BytesSource2.java rename src/main/java/com/android/tools/build/bundletool/model/{DeviceTiersCondition.java => DeviceGroupsCondition.java} (77%) rename src/main/java/com/android/tools/build/bundletool/model/utils/{DeviceTierUtils.java => DeviceTargetingUtils.java} (70%) create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/ApkModeTransparencyChecker.java create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/ApkSignatureVerifier.java create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/ApkTransparencyCheckUtils.java create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/BundleModeTransparencyChecker.java create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/BundleTransparencyCheckUtils.java delete mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyChecker.java create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyCryptoUtils.java create mode 100644 src/main/java/com/android/tools/build/bundletool/transparency/ConnectedDeviceModeTransparencyChecker.java create mode 100644 src/main/proto/rotation_config.proto delete mode 100644 src/test/java/com/android/tools/build/bundletool/commands/ApkTransparencyCheckerTest.java rename src/test/java/com/android/tools/build/bundletool/device/{DeviceTierModuleMatcherTest.java => DeviceGroupModuleMatcherTest.java} (78%) create mode 100644 src/test/java/com/android/tools/build/bundletool/device/LocalTestingPathResolverTest.java create mode 100644 src/test/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjectorTest.java create mode 100644 src/test/java/com/android/tools/build/bundletool/testing/CodeTransparencyTestUtils.java create mode 100644 src/test/resources/com/android/tools/build/bundletool/testdata/dex/classes-emulated-coredesugar.dex diff --git a/README.md b/README.md index 8e082ed2..e89d66fb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ Bundletool has a few different responsibilities: * **Extract device spec** from a device as a JSON file. +* **Add code transparency** to an Android App Bundle. Code transparency is an + optional code signing mechanism. + +* **Verify code transparency** inside an Android App Bundle, APK files or an + application installed on a connected device. Read more about the App Bundle format and Bundletool's usage at [g.co/androidappbundle](https://g.co/androidappbundle) @@ -26,4 +31,4 @@ https://developer.android.com/studio/command-line/bundletool ## Releases -Latest release: [1.6.1](https://github.com/google/bundletool/releases) +Latest release: [1.7.0](https://github.com/google/bundletool/releases) diff --git a/build.gradle b/build.gradle index 452198a6..511f02c5 100644 --- a/build.gradle +++ b/build.gradle @@ -32,13 +32,13 @@ configurations { // The repackaging rules are defined in the "shadowJar" task below. dependencies { compile "com.android.tools:common:30.0.0-alpha10" - compile "com.android.tools:r8:2.1.66" + compile "com.android.tools:r8:2.2.64" compile "com.android.tools.build:apkzlib:4.2.0-alpha13" compile "com.android.tools.build:apksig:4.2.0-alpha13" compile "com.android.tools.ddms:ddmlib:30.0.0-alpha10" compile "com.android:zipflinger:7.0.0-alpha14" - shadow "com.android.tools.build:aapt2-proto:4.1.0-alpha01-6193524" + shadow "com.android.tools.build:aapt2-proto:7.0.0-beta04-7396180" shadow "com.google.auto.value:auto-value-annotations:1.6.2" annotationProcessor "com.google.auto.value:auto-value:1.6.2" shadow "com.google.errorprone:error_prone_annotations:2.3.1" @@ -48,13 +48,18 @@ dependencies { shadow "com.google.dagger:dagger:2.28.3" annotationProcessor "com.google.dagger:dagger-compiler:2.28.3" shadow "javax.inject:javax.inject:1" - shadow "org.bitbucket.b_c:jose4j:0.7.0" + shadow("org.bitbucket.b_c:jose4j:0.7.0") { + exclude group: "org.slf4j", module: "slf4j-api" + } + shadow "org.slf4j:slf4j-api:1.7.30" + + compileWindows "com.android.tools.build:aapt2:7.0.0-beta04-7396180:windows" + compileMacOs "com.android.tools.build:aapt2:7.0.0-beta04-7396180:osx" + compileLinux "com.android.tools.build:aapt2:7.0.0-beta04-7396180:linux" - compileWindows "com.android.tools.build:aapt2:4.1.0-alpha01-6193524:windows" - compileMacOs "com.android.tools.build:aapt2:4.1.0-alpha01-6193524:osx" - compileLinux "com.android.tools.build:aapt2:4.1.0-alpha01-6193524:linux" + runtime "org.slf4j:slf4j-jdk14:1.7.30" - testCompile "com.android.tools.build:aapt2-proto:4.1.0-alpha01-6193524" + testCompile "com.android.tools.build:aapt2-proto:7.0.0-beta04-7396180" testCompile "com.google.auto.value:auto-value-annotations:1.6.2" testAnnotationProcessor "com.google.auto.value:auto-value:1.6.2" testCompile "com.google.errorprone:error_prone_annotations:2.3.1" @@ -76,7 +81,11 @@ dependencies { testCompile("org.smali:dexlib2:2.3.4") { exclude group: "com.google.guava", module: "guava" } - testCompile "org.bitbucket.b_c:jose4j:0.7.0" + testCompile("org.bitbucket.b_c:jose4j:0.7.0") { + exclude group: "org.slf4j", module: "slf4j-api" + } + testCompile "org.slf4j:slf4j-api:1.7.30" + testRuntime "org.slf4j:slf4j-jdk14:1.7.30" } def osName = System.getProperty("os.name").toLowerCase() diff --git a/gradle.properties b/gradle.properties index 8c6176d6..3bf72790 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -release_version = 1.6.1 +release_version = 1.7.0 diff --git a/src/main/java/com/android/tools/build/bundletool/BundleToolMain.java b/src/main/java/com/android/tools/build/bundletool/BundleToolMain.java index c203f018..bec0c0ee 100644 --- a/src/main/java/com/android/tools/build/bundletool/BundleToolMain.java +++ b/src/main/java/com/android/tools/build/bundletool/BundleToolMain.java @@ -108,6 +108,14 @@ static void main(String[] args, Runtime runtime) { case VersionCommand.COMMAND_NAME: VersionCommand.fromFlags(flags, System.out).execute(); break; + case AddTransparencyCommand.COMMAND_NAME: + AddTransparencyCommand.fromFlags(flags).execute(); + break; + case CheckTransparencyCommand.COMMAND_NAME: + try (AdbServer adbServer = DdmlibAdbServer.getInstance()) { + CheckTransparencyCommand.fromFlags(flags, adbServer).execute(); + } + break; case HELP_CMD: if (flags.getSubCommand().isPresent()) { help(flags.getSubCommand().get(), runtime); @@ -186,6 +194,12 @@ public static void help(String commandName, Runtime runtime) { case GetSizeCommand.COMMAND_NAME: commandHelp = GetSizeCommand.help(); break; + case AddTransparencyCommand.COMMAND_NAME: + commandHelp = AddTransparencyCommand.help(); + break; + case CheckTransparencyCommand.COMMAND_NAME: + commandHelp = CheckTransparencyCommand.help(); + break; default: System.err.printf("Error: Unrecognized command '%s'.%n%n%n", commandName); help(); diff --git a/src/main/java/com/android/tools/build/bundletool/commands/AddTransparencyCommand.java b/src/main/java/com/android/tools/build/bundletool/commands/AddTransparencyCommand.java index 71b72177..40275c1a 100644 --- a/src/main/java/com/android/tools/build/bundletool/commands/AddTransparencyCommand.java +++ b/src/main/java/com/android/tools/build/bundletool/commands/AddTransparencyCommand.java @@ -15,6 +15,9 @@ */ package com.android.tools.build.bundletool.commands; +import static com.android.tools.build.bundletool.transparency.CodeTransparencyCryptoUtils.getX509Certificate; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; import static org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256; import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; @@ -27,11 +30,17 @@ import com.android.tools.build.bundletool.model.BundleMetadata; import com.android.tools.build.bundletool.model.Password; import com.android.tools.build.bundletool.model.SignerConfig; +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; +import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; import com.android.tools.build.bundletool.model.utils.files.FilePreconditions; +import com.android.tools.build.bundletool.transparency.BundleTransparencyCheckUtils; import com.android.tools.build.bundletool.transparency.CodeTransparencyFactory; import com.google.auto.value.AutoValue; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Ascii; import com.google.common.base.Preconditions; +import com.google.common.io.BaseEncoding; import com.google.common.io.ByteSource; import com.google.common.io.CharSource; import com.google.protobuf.InvalidProtocolBufferException; @@ -39,9 +48,11 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.security.cert.X509Certificate; import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; import java.util.Optional; import java.util.zip.ZipException; import java.util.zip.ZipFile; @@ -56,8 +67,35 @@ public abstract class AddTransparencyCommand { public static final String COMMAND_NAME = "add-transparency"; + /** Mode to run {@link AddTransparencyCommand} against. */ + public enum Mode { + DEFAULT, + GENERATE_CODE_TRANSPARENCY_FILE, + INJECT_SIGNATURE; + + final String getLowerCaseName() { + return Ascii.toLowerCase(name()); + } + } + + /** + * Defines a command behaviour when some APKs generated from the input App Bundle requires dex + * merging. + */ + public enum DexMergingChoice { + ASK_IN_CONSOLE, + CONTINUE, + REJECT; + + final String getLowerCaseName() { + return Ascii.toLowerCase(name()); + } + } + static final int MIN_RSA_KEY_LENGTH = 3072; + private static final Flag MODE_FLAG = Flag.enumFlag("mode", Mode.class); + private static final Flag BUNDLE_LOCATION_FLAG = Flag.path("bundle"); private static final Flag OUTPUT_FLAG = Flag.path("output"); @@ -70,36 +108,84 @@ public abstract class AddTransparencyCommand { private static final Flag KEY_PASSWORD_FLAG = Flag.password("key-pass"); + private static final Flag TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG = + Flag.path("transparency-key-certificate"); + + private static final Flag TRANSPARENCY_SIGNATURE_LOCATION_FLAG = + Flag.path("transparency-signature"); + + private static final Flag DEX_MERGING_CHOICE_FLAG = + Flag.enumFlag("dex-merging-choice", DexMergingChoice.class); + + public abstract Mode getMode(); + public abstract Path getBundlePath(); public abstract Path getOutputPath(); - public abstract SignerConfig getSignerConfig(); + public abstract DexMergingChoice getDexMergingChoice(); + + public abstract Optional getSignerConfig(); + + public abstract Optional getTransparencyKeyCertificate(); + + public abstract Optional getTransparencySignaturePath(); public static AddTransparencyCommand.Builder builder() { - return new AutoValue_AddTransparencyCommand.Builder(); + return new AutoValue_AddTransparencyCommand.Builder() + .setMode(Mode.DEFAULT) + .setDexMergingChoice(DexMergingChoice.ASK_IN_CONSOLE); } /** Builder for the {@link AddTransparencyCommand}. */ @AutoValue.Builder public abstract static class Builder { + /** Sets the mode to run the command against. */ + public abstract Builder setMode(Mode mode); + /** Sets the path to the input bundle. Must have the extension ".aab". */ - public abstract AddTransparencyCommand.Builder setBundlePath(Path bundlePath); + public abstract Builder setBundlePath(Path bundlePath); /** - * Sets the path to the output bundle. Must have the extension ".aab". If the output file - * already exists, it can not be overwritten. + * Sets the path to the output file. If the output file already exists, it can not be + * overwritten. */ - public abstract AddTransparencyCommand.Builder setOutputPath(Path bundlePath); + public abstract Builder setOutputPath(Path bundlePath); /** Sets code transparency signer configuration. */ - public abstract AddTransparencyCommand.Builder setSignerConfig(SignerConfig signerConfig); + public abstract Builder setSignerConfig(SignerConfig signerConfig); + + /** Sets the public key certificate of the code transparency key. */ + public abstract Builder setTransparencyKeyCertificate( + X509Certificate transparencyKeyCertificate); + + /** Sets path to the file containing code transparency signature. */ + public abstract Builder setTransparencySignaturePath(Path transparencySignaturePath); + + /** + * Sets how command should behave when dex merging is required for some APKs generated from the + * input App Bundle. + */ + public abstract Builder setDexMergingChoice(DexMergingChoice value); public abstract AddTransparencyCommand build(); } public static AddTransparencyCommand fromFlags(ParsedFlags flags) { + Mode mode = MODE_FLAG.getValue(flags).orElse(Mode.DEFAULT); + switch (mode) { + case DEFAULT: + return fromFlagsInDefaultMode(flags); + case GENERATE_CODE_TRANSPARENCY_FILE: + return fromFlagsInGenerateGenerateCodeTransparencyFileMode(flags); + case INJECT_SIGNATURE: + return fromFlagsInInjectSignatureMode(flags); + } + throw new IllegalStateException("Unrecognized value of --mode flag."); + } + + private static AddTransparencyCommand fromFlagsInDefaultMode(ParsedFlags flags) { Path keystorePath = KEYSTORE_FLAG.getRequiredValue(flags); String keyAlias = KEY_ALIAS_FLAG.getRequiredValue(flags); Optional keystorePassword = KEYSTORE_PASSWORD_FLAG.getValue(flags); @@ -108,15 +194,47 @@ public static AddTransparencyCommand fromFlags(ParsedFlags flags) { SignerConfig.extractFromKeystore(keystorePath, keyAlias, keystorePassword, keyPassword); AddTransparencyCommand.Builder addTransparencyCommandBuilder = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(BUNDLE_LOCATION_FLAG.getRequiredValue(flags)) .setOutputPath(OUTPUT_FLAG.getRequiredValue(flags)) + .setDexMergingChoice( + DEX_MERGING_CHOICE_FLAG.getValue(flags).orElse(DexMergingChoice.ASK_IN_CONSOLE)) .setSignerConfig(signerConfig); flags.checkNoUnknownFlags(); return addTransparencyCommandBuilder.build(); } + private static AddTransparencyCommand fromFlagsInGenerateGenerateCodeTransparencyFileMode( + ParsedFlags flags) { + AddTransparencyCommand.Builder addTransparencyCommandBuilder = + AddTransparencyCommand.builder() + .setMode(Mode.GENERATE_CODE_TRANSPARENCY_FILE) + .setBundlePath(BUNDLE_LOCATION_FLAG.getRequiredValue(flags)) + .setOutputPath(OUTPUT_FLAG.getRequiredValue(flags)) + .setTransparencyKeyCertificate( + getX509Certificate( + TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG.getRequiredValue(flags))); + flags.checkNoUnknownFlags(); + return addTransparencyCommandBuilder.build(); + } + + private static AddTransparencyCommand fromFlagsInInjectSignatureMode(ParsedFlags flags) { + AddTransparencyCommand.Builder addTransparencyCommandBuilder = + AddTransparencyCommand.builder() + .setMode(Mode.INJECT_SIGNATURE) + .setBundlePath(BUNDLE_LOCATION_FLAG.getRequiredValue(flags)) + .setOutputPath(OUTPUT_FLAG.getRequiredValue(flags)) + .setTransparencySignaturePath( + TRANSPARENCY_SIGNATURE_LOCATION_FLAG.getRequiredValue(flags)) + .setTransparencyKeyCertificate( + getX509Certificate( + TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG.getRequiredValue(flags))); + flags.checkNoUnknownFlags(); + return addTransparencyCommandBuilder.build(); + } + public void execute() { - validateInputs(); + validateCommonInputs(); try (ZipFile bundleZip = new ZipFile(getBundlePath().toFile())) { AppBundle inputBundle = AppBundle.buildFromZip(bundleZip); @@ -127,17 +245,28 @@ public void execute() { + " one of the manifests.") .build(); } - String jsonText = - toJsonText(CodeTransparencyFactory.createCodeTransparencyMetadata(inputBundle)); - AppBundle.Builder bundleBuilder = inputBundle.toBuilder(); - bundleBuilder.setBundleMetadata( - inputBundle.getBundleMetadata().toBuilder() - .addFile( - BundleMetadata.BUNDLETOOL_NAMESPACE, - BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, - toBytes(createJwsToken(jsonText))) - .build()); - new AppBundleSerializer().writeToDisk(bundleBuilder.build(), getOutputPath()); + if (inputBundle.dexMergingEnabled()) { + DexMergingChoice choice = evaluateDexMergingChoice(); + if (choice.equals(DexMergingChoice.REJECT)) { + throw InvalidCommandException.builder() + .withInternalMessage( + "'add-transparency' command is rejected because one of generated " + + "standalone/universal APKs will require dex merging and it is requested to" + + "reject command in this case.") + .build(); + } + } + switch (getMode()) { + case DEFAULT: + executeDefaultMode(inputBundle); + break; + case GENERATE_CODE_TRANSPARENCY_FILE: + executeGenerateCodeTransparencyFileMode(inputBundle); + break; + case INJECT_SIGNATURE: + executeInjectSignatureMode(inputBundle); + break; + } } catch (ZipException e) { throw InvalidBundleException.builder() .withCause(e) @@ -151,7 +280,87 @@ public void execute() { } } + private DexMergingChoice evaluateDexMergingChoice() { + switch (getDexMergingChoice()) { + case REJECT: + case CONTINUE: + return getDexMergingChoice(); + case ASK_IN_CONSOLE: + String userDecision = + System.console() + .readLine( + "You will not be able to verify code transparency for standalone and universal" + + " APKs generated from this bundle. Reason: bundletool will merge dex" + + " files when generating standalone APKs. This happens for applications" + + " with dynamic feature modules that have min sdk below 21 and specify" + + " DexMergingStrategy.MERGE_IF_NEEDED.\nWould you like to continue?" + + " [yes/no]:"); + return Ascii.equalsIgnoreCase(userDecision, "yes") + ? DexMergingChoice.CONTINUE + : DexMergingChoice.REJECT; + } + throw new IllegalStateException("Unsupported DexMergingChoice"); + } + + private void executeDefaultMode(AppBundle inputBundle) throws IOException, JoseException { + validateDefaultModeInputs(); + String jsonText = + toJsonText(CodeTransparencyFactory.createCodeTransparencyMetadata(inputBundle)); + AppBundle.Builder bundleBuilder = inputBundle.toBuilder(); + bundleBuilder.setBundleMetadata( + inputBundle.getBundleMetadata().toBuilder() + .addFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + toBytes( + createSignedJwt(jsonText, getSignerConfig().get().getCertificates().get(0)))) + .build()); + new AppBundleSerializer().writeToDisk(bundleBuilder.build(), getOutputPath()); + } + + private void executeGenerateCodeTransparencyFileMode(AppBundle inputBundle) throws IOException { + validateGenerateCodeTransparencyFileModeInputs(); + String codeTransparencyMetadata = + toJsonText(CodeTransparencyFactory.createCodeTransparencyMetadata(inputBundle)); + Files.write( + getOutputPath(), + toBytes( + createJwtWithoutSignature( + codeTransparencyMetadata, getTransparencyKeyCertificate().get())) + .read()); + } + + private void executeInjectSignatureMode(AppBundle inputBundle) throws IOException { + validateInjectSignatureModeInputs(); + String signature = + BaseEncoding.base64Url().encode(Files.readAllBytes(getTransparencySignaturePath().get())); + String codeTransparencyMetadata = + toJsonText(CodeTransparencyFactory.createCodeTransparencyMetadata(inputBundle)); + String transparencyFileWithoutSignature = + createJwtWithoutSignature(codeTransparencyMetadata, getTransparencyKeyCertificate().get()); + AppBundle bundleWithTransparency = + inputBundle.toBuilder() + .setBundleMetadata( + inputBundle.getBundleMetadata().toBuilder() + .addFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + toBytes(transparencyFileWithoutSignature + "." + signature)) + .build()) + .build(); + if (!BundleTransparencyCheckUtils.checkTransparency(bundleWithTransparency).verified()) { + throw CommandExecutionException.builder() + .withInternalMessage( + "Code transparency verification failed for the provided public key certificate and" + + " signature.") + .build(); + } + new AppBundleSerializer().writeToDisk(bundleWithTransparency, getOutputPath()); + } + public static CommandHelp help() { + String modeFlagOptions = + stream(Mode.values()).map(Mode::getLowerCaseName).collect(joining("|")); return CommandHelp.builder() .setCommandName(COMMAND_NAME) .setCommandDescription( @@ -159,6 +368,24 @@ public static CommandHelp help() { .setShortDescription( "Generates code transparency file and adds it to the output bundle.") .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(MODE_FLAG.getName()) + .setExampleValue(modeFlagOptions) + .setOptional(true) + .setDescription( + "Specifies which mode to run '%s' command against. Acceptable values are '%s'." + + " If set to '%s' we generate a signed code transparency file and include" + + " it into the output bundle. If set to '%s' we generate unsigned" + + " transparency file. If set to '%s' we inject the provided signed" + + " transparency file into the bundle. The default value is '%s'.", + AddTransparencyCommand.COMMAND_NAME, + modeFlagOptions, + Mode.DEFAULT.getLowerCaseName(), + Mode.GENERATE_CODE_TRANSPARENCY_FILE.getLowerCaseName(), + Mode.INJECT_SIGNATURE.getLowerCaseName(), + Mode.DEFAULT.getLowerCaseName()) + .build()) .addFlag( FlagDescription.builder() .setFlagName(BUNDLE_LOCATION_FLAG.getName()) @@ -169,19 +396,24 @@ public static CommandHelp help() { .addFlag( FlagDescription.builder() .setFlagName(OUTPUT_FLAG.getName()) - .setExampleValue("path/to/bundle_with_transparency.aab") - .setDescription("Path to where the output bundle should be written.") + .setExampleValue("path/to/[bundle_with_transparency.aab|transparency_file.jwe]") + .setDescription( + "Path to where the output file should be written. Must have extension .aab in" + + " '%s' and '%s' modes.", + Mode.DEFAULT.getLowerCaseName(), Mode.INJECT_SIGNATURE.getLowerCaseName()) .build()) .addFlag( FlagDescription.builder() .setFlagName(KEYSTORE_FLAG.getName()) .setExampleValue("path/to/keystore") + .setOptional(true) .setDescription( "Path to the keystore that should be used to sign the code transparency file.") .build()) .addFlag( FlagDescription.builder() .setFlagName(KEY_ALIAS_FLAG.getName()) + .setOptional(true) .setExampleValue("key-alias") .setDescription( "Alias of the key to use in the keystore to sign the code transparency file.") @@ -211,17 +443,67 @@ public static CommandHelp help() { + " is not set, the keystore password will be tried. If that fails, the" + " password will be requested on the prompt.") .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG.getName()) + .setExampleValue("path/to/certificate.cert") + .setOptional(true) + .setDescription( + "Path to the file containing the code transparency public key certificate." + + " Required in '%s' and '%s' modes. Should not be used in other modes.", + Mode.GENERATE_CODE_TRANSPARENCY_FILE.getLowerCaseName(), + Mode.INJECT_SIGNATURE.getLowerCaseName()) + .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(TRANSPARENCY_SIGNATURE_LOCATION_FLAG.getName()) + .setExampleValue("path/to/transparency.signature") + .setOptional(true) + .setDescription( + "Path to the file containing the code transparency file signature. Required in" + + " '%s' mode. Should not be used in other modes.", + Mode.INJECT_SIGNATURE.getLowerCaseName()) + .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(DEX_MERGING_CHOICE_FLAG.getName()) + .setExampleValue( + String.format( + "%s|%s", + DexMergingChoice.CONTINUE.getLowerCaseName(), + DexMergingChoice.REJECT.getLowerCaseName())) + .setOptional(true) + .setDescription( + "Allows to silently respond how 'add-transparency' command should " + + "behave if some of generated standalone/universal APKs will require dex " + + "merging. '%s' means that 'add-transparency' should add code " + + "transparency anyway, but it won't be propagated to these APKs. '%s' " + + "means that 'add-transparency' command should fail. By default, if this " + + "choice is required user will be asked in terminal.", + DexMergingChoice.CONTINUE.getLowerCaseName(), + DexMergingChoice.REJECT.getLowerCaseName()) + .build()) .build(); } - private String createJwsToken(String payload) throws JoseException { + private String createSignedJwt(String payload, X509Certificate certificate) throws JoseException { + JsonWebSignature jws = createJwsCommon(payload, certificate); + jws.setKey(getSignerConfig().get().getPrivateKey()); + return jws.getCompactSerialization(); + } + + @VisibleForTesting + static String createJwtWithoutSignature(String payload, X509Certificate certificate) { + JsonWebSignature jws = createJwsCommon(payload, certificate); + return jws.getHeaders().getEncodedHeader() + "." + jws.getEncodedPayload(); + } + + private static JsonWebSignature createJwsCommon(String payload, X509Certificate certificate) { JsonWebSignature jws = new JsonWebSignature(); jws.setAlgorithmHeaderValue(RSA_USING_SHA256); - jws.setCertificateChainHeaderValue( - getSignerConfig().getCertificates().toArray(new X509Certificate[0])); + jws.setCertificateChainHeaderValue(certificate); jws.setPayload(payload); - jws.setKey(getSignerConfig().getPrivateKey()); - return jws.getCompactSerialization(); + return jws; } private static String toJsonText(CodeTransparency codeTransparency) @@ -233,21 +515,52 @@ private static ByteSource toBytes(String content) { return CharSource.wrap(content).asByteSource(Charset.defaultCharset()); } - private void validateInputs() { + private void validateCommonInputs() { FilePreconditions.checkFileHasExtension("AAB file", getBundlePath(), ".aab"); FilePreconditions.checkFileExistsAndReadable(getBundlePath()); + } + + private void validateDefaultModeInputs() { FilePreconditions.checkFileHasExtension("AAB file", getOutputPath(), ".aab"); FilePreconditions.checkFileDoesNotExist(getOutputPath()); Preconditions.checkArgument( - getSignerConfig().getPrivateKey().getAlgorithm().equals(RsaKeyUtil.RSA), + getSignerConfig().get().getPrivateKey().getAlgorithm().equals(RsaKeyUtil.RSA), "Transparency signing key must be an RSA key, but %s key was provided.", - getSignerConfig().getPrivateKey().getAlgorithm()); - int keyLength = ((RSAPrivateKey) getSignerConfig().getPrivateKey()).getModulus().bitLength(); + getSignerConfig().get().getPrivateKey().getAlgorithm()); + int keyLength = + ((RSAPrivateKey) getSignerConfig().get().getPrivateKey()).getModulus().bitLength(); Preconditions.checkArgument( keyLength >= MIN_RSA_KEY_LENGTH, "Minimum required key length is %s bits, but %s bit key was provided.", MIN_RSA_KEY_LENGTH, keyLength); } -} + private void validateGenerateCodeTransparencyFileModeInputs() { + FilePreconditions.checkFileDoesNotExist(getOutputPath()); + validateTransparencyKeyCertificate(); + } + + private void validateInjectSignatureModeInputs() { + FilePreconditions.checkFileHasExtension("AAB file", getOutputPath(), ".aab"); + FilePreconditions.checkFileDoesNotExist(getOutputPath()); + FilePreconditions.checkFileExistsAndReadable(getTransparencySignaturePath().get()); + validateTransparencyKeyCertificate(); + } + + private void validateTransparencyKeyCertificate() { + Preconditions.checkArgument( + getTransparencyKeyCertificate().get().getPublicKey().getAlgorithm().equals(RsaKeyUtil.RSA), + "Transparency signing key must be an RSA key, but %s key was provided.", + getTransparencyKeyCertificate().get().getPublicKey().getAlgorithm()); + int keyLength = + ((RSAPublicKey) getTransparencyKeyCertificate().get().getPublicKey()) + .getModulus() + .bitLength(); + Preconditions.checkArgument( + keyLength >= MIN_RSA_KEY_LENGTH, + "Minimum required key length is %s bits, but %s bit key was provided.", + MIN_RSA_KEY_LENGTH, + keyLength); + } +} diff --git a/src/main/java/com/android/tools/build/bundletool/commands/ApkTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/commands/ApkTransparencyChecker.java deleted file mode 100644 index 21a47b26..00000000 --- a/src/main/java/com/android/tools/build/bundletool/commands/ApkTransparencyChecker.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ -package com.android.tools.build.bundletool.commands; - -import static com.google.common.collect.ImmutableList.toImmutableList; - -import com.android.tools.build.bundletool.io.TempDirectory; -import com.android.tools.build.bundletool.model.BundleMetadata; -import com.android.tools.build.bundletool.model.ZipPath; -import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; -import com.android.tools.build.bundletool.model.utils.ZipUtils; -import com.google.common.collect.ImmutableList; -import com.google.common.io.ByteStreams; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Locale; -import java.util.Optional; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; - -/** Checks code transparency for a given set of a device-specific APK files. */ -final class ApkTransparencyChecker { - - private static final String TRANSPARENCY_FILE_ZIP_ENTRY_NAME = - "META-INF/" + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME; - - static void checkTransparency(CheckTransparencyCommand command, PrintStream outputStream) { - try (TempDirectory tempDir = new TempDirectory("apk-transparency-checker")) { - ImmutableList allApkPaths = - extractAllApksFromZip(command.getApkZipPath().get(), tempDir); - Optional baseApkPath = getBaseApkPath(allApkPaths); - if (!baseApkPath.isPresent()) { - throw InvalidCommandException.builder() - .withInternalMessage( - "The provided .zip file must either contain a single APK, or, if multiple APK" - + " files are present, a base APK.") - .build(); - } - - ZipFile baseApkFile = ZipUtils.openZipFile(baseApkPath.get()); - Optional transparencyFileEntry = - Optional.ofNullable(baseApkFile.getEntry(TRANSPARENCY_FILE_ZIP_ENTRY_NAME)); - if (!transparencyFileEntry.isPresent()) { - throw InvalidCommandException.builder() - .withInternalMessage( - "Could not verify code transparency because transparency file is not present in the" - + " APK.") - .build(); - } - } catch (IOException e) { - throw new UncheckedIOException("An error occurred when processing the file.", e); - } - } - - /** Returns list of paths to all .apk files extracted from a .zip file. */ - private static ImmutableList extractAllApksFromZip( - Path zipOfApksPath, TempDirectory tempDirectory) throws IOException { - ImmutableList.Builder allExtractedApkPaths = ImmutableList.builder(); - Path zipExtractedSubDirectory = tempDirectory.getPath().resolve("extracted"); - Files.createDirectory(zipExtractedSubDirectory); - - ZipFile zipOfApks = ZipUtils.openZipFile(zipOfApksPath); - ImmutableList listOfApksToExtract = - zipOfApks.stream() - .filter( - zipEntry -> - !zipEntry.isDirectory() - && zipEntry.getName().toLowerCase(Locale.ROOT).endsWith(".apk")) - .collect(toImmutableList()); - - for (ZipEntry apkToExtract : listOfApksToExtract) { - Path extractedApkPath = - zipExtractedSubDirectory.resolve(ZipPath.create(apkToExtract.getName()).toString()); - Files.createDirectories(extractedApkPath.getParent()); - try (InputStream inputStream = zipOfApks.getInputStream(apkToExtract); - OutputStream outputApk = Files.newOutputStream(extractedApkPath)) { - ByteStreams.copy(inputStream, outputApk); - allExtractedApkPaths.add(extractedApkPath); - } - } - - return allExtractedApkPaths.build(); - } - - private static Optional getBaseApkPath(ImmutableList apkPaths) { - // If only 1 APK is present in the archive, it is assumed to be a universal or standalone APK. - if (apkPaths.size() == 1) { - return apkPaths.get(0).getFileName().toString().endsWith(".apk") - ? Optional.of(apkPaths.get(0)) - : Optional.empty(); - } - return apkPaths.stream() - .filter(apkPath -> apkPath.getFileName().toString().equals("base.apk")) - .findAny(); - } - - private ApkTransparencyChecker() {} -} diff --git a/src/main/java/com/android/tools/build/bundletool/commands/BuildApksManager.java b/src/main/java/com/android/tools/build/bundletool/commands/BuildApksManager.java index 33bd9103..49e49835 100644 --- a/src/main/java/com/android/tools/build/bundletool/commands/BuildApksManager.java +++ b/src/main/java/com/android/tools/build/bundletool/commands/BuildApksManager.java @@ -310,10 +310,12 @@ private ApkGenerationConfiguration.Builder getCommonSplitApkGenerationConfigurat apkGenerationConfiguration.setSuffixStrippings(apkOptimizations.getSuffixStrippings()); - command.getSigningConfiguration().ifPresent( - signingConfig -> - apkGenerationConfiguration.setMinimumV3SigningApiVersion( - signingConfig.getMinimumV3SigningApiVersion())); + command + .getSigningConfiguration() + .ifPresent( + signingConfig -> + apkGenerationConfiguration.setMinimumV3SigningApiVersion( + signingConfig.getMinimumV3SigningApiVersion())); return apkGenerationConfiguration; } diff --git a/src/main/java/com/android/tools/build/bundletool/commands/BundleTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/commands/BundleTransparencyChecker.java deleted file mode 100644 index 378e0271..00000000 --- a/src/main/java/com/android/tools/build/bundletool/commands/BundleTransparencyChecker.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ -package com.android.tools.build.bundletool.commands; - -import com.android.tools.build.bundletool.model.AppBundle; -import com.android.tools.build.bundletool.model.BundleMetadata; -import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; -import com.android.tools.build.bundletool.transparency.CodeTransparencyChecker; -import com.android.tools.build.bundletool.transparency.TransparencyCheckResult; -import com.google.common.io.ByteSource; -import java.io.IOException; -import java.io.PrintStream; -import java.io.UncheckedIOException; -import java.util.Optional; -import java.util.zip.ZipException; -import java.util.zip.ZipFile; - -/** Checks code transparency in a given bundle. */ -final class BundleTransparencyChecker { - - static void checkTransparency(CheckTransparencyCommand command, PrintStream outputStream) { - try (ZipFile bundleZip = new ZipFile(command.getBundlePath().get().toFile())) { - AppBundle inputBundle = AppBundle.buildFromZip(bundleZip); - Optional signedTransparencyFile = - inputBundle - .getBundleMetadata() - .getFileAsByteSource( - BundleMetadata.BUNDLETOOL_NAMESPACE, - BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME); - if (!signedTransparencyFile.isPresent()) { - throw InvalidBundleException.builder() - .withUserMessage( - "Bundle does not include code transparency metadata. Run `add-transparency`" - + " command to add code transparency metadata to the bundle.") - .build(); - } - TransparencyCheckResult transparencyCheckResult = - CodeTransparencyChecker.checkTransparency(inputBundle, signedTransparencyFile.get()); - if (!transparencyCheckResult.signatureVerified()) { - outputStream.print("Code transparency verification failed because signature is invalid."); - } else if (!transparencyCheckResult.fileContentsVerified()) { - outputStream.print( - "Code transparency verification failed because code was modified after transparency" - + " metadata generation.\n" - + transparencyCheckResult.getDiffAsString()); - } else { - outputStream.print( - "Code transparency verified. Public key certificate fingerprint: " - + transparencyCheckResult.certificateThumbprint().get()); - } - } catch (ZipException e) { - throw InvalidBundleException.builder() - .withCause(e) - .withUserMessage("The App Bundle is not a valid zip file.") - .build(); - } catch (IOException e) { - throw new UncheckedIOException("An error occurred when processing the App Bundle.", e); - } - } - - private BundleTransparencyChecker() {} -} diff --git a/src/main/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommand.java b/src/main/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommand.java index bdfa7a9e..bc773991 100644 --- a/src/main/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommand.java +++ b/src/main/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommand.java @@ -18,6 +18,7 @@ import static com.android.tools.build.bundletool.commands.CommandUtils.ANDROID_SERIAL_VARIABLE; import static com.android.tools.build.bundletool.model.utils.SdkToolsLocator.ANDROID_HOME_VARIABLE; import static com.android.tools.build.bundletool.model.utils.SdkToolsLocator.SYSTEM_PATH_VARIABLE; +import static com.android.tools.build.bundletool.transparency.CodeTransparencyCryptoUtils.getX509Certificate; import static java.util.Arrays.stream; import static java.util.stream.Collectors.joining; @@ -29,10 +30,17 @@ import com.android.tools.build.bundletool.model.utils.DefaultSystemEnvironmentProvider; import com.android.tools.build.bundletool.model.utils.SystemEnvironmentProvider; import com.android.tools.build.bundletool.model.utils.files.FilePreconditions; +import com.android.tools.build.bundletool.transparency.ApkModeTransparencyChecker; +import com.android.tools.build.bundletool.transparency.BundleModeTransparencyChecker; +import com.android.tools.build.bundletool.transparency.CodeTransparencyCryptoUtils; +import com.android.tools.build.bundletool.transparency.ConnectedDeviceModeTransparencyChecker; +import com.android.tools.build.bundletool.transparency.TransparencyCheckResult; import com.google.auto.value.AutoValue; import com.google.common.base.Ascii; +import com.google.common.base.Preconditions; import java.io.PrintStream; import java.nio.file.Path; +import java.security.cert.X509Certificate; import java.util.Optional; /** Command to verify code transparency. */ @@ -41,7 +49,8 @@ public abstract class CheckTransparencyCommand { public static final String COMMAND_NAME = "check-transparency"; - enum Mode { + /** Mode to run {@link CheckTransparencyCommand} against. */ + public enum Mode { CONNECTED_DEVICE, BUNDLE, APK; @@ -57,10 +66,18 @@ final String getLowerCaseName() { private static final Flag DEVICE_ID_FLAG = Flag.string("device-id"); + private static final Flag PACKAGE_NAME_FLAG = Flag.string("package-name"); + private static final Flag BUNDLE_LOCATION_FLAG = Flag.path("bundle"); private static final Flag APK_ZIP_LOCATION_FLAG = Flag.path("apk-zip"); + private static final Flag TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG = + Flag.path("transparency-key-certificate"); + + private static final Flag APK_SIGNING_KEY_CERTIFICATE_LOCATION_FLAG = + Flag.path("apk-signing-key-certificate"); + private static final String MODE_FLAG_OPTIONS = stream(Mode.values()).map(Mode::getLowerCaseName).collect(joining("|")); @@ -73,12 +90,18 @@ final String getLowerCaseName() { public abstract Optional getDeviceId(); + public abstract Optional getPackageName(); + public abstract Optional getAdbServer(); public abstract Optional getBundlePath(); public abstract Optional getApkZipPath(); + abstract Optional getTransparencyKeyCertificate(); + + abstract Optional getApkSigningKeyCertificate(); + public static CheckTransparencyCommand.Builder builder() { return new AutoValue_CheckTransparencyCommand.Builder(); } @@ -95,12 +118,18 @@ public abstract static class Builder { public abstract Builder setAdbServer(AdbServer adbServer); + public abstract Builder setPackageName(String packageName); + /** Sets the path to the input bundle. Must have the extension ".aab". */ public abstract Builder setBundlePath(Path bundlePath); /** Sets the path to the .zip archive of device-specific APK files. */ public abstract Builder setApkZipPath(Path apkZipPath); + abstract Builder setTransparencyKeyCertificate(X509Certificate transparencyKeyCertificate); + + abstract Builder setApkSigningKeyCertificate(X509Certificate apkSigningKeyCertificate); + public abstract CheckTransparencyCommand build(); } @@ -123,29 +152,43 @@ public static CheckTransparencyCommand fromFlags( } private static CheckTransparencyCommand fromFlagsInBundleMode(ParsedFlags flags) { - CheckTransparencyCommand checkTransparencyCommand = + CheckTransparencyCommand.Builder checkTransparencyCommand = CheckTransparencyCommand.builder() .setMode(Mode.BUNDLE) - .setBundlePath(BUNDLE_LOCATION_FLAG.getRequiredValue(flags)) - .build(); + .setBundlePath(BUNDLE_LOCATION_FLAG.getRequiredValue(flags)); + TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG + .getValue(flags) + .ifPresent( + path -> + checkTransparencyCommand.setTransparencyKeyCertificate(getX509Certificate(path))); flags.checkNoUnknownFlags(); - return checkTransparencyCommand; + return checkTransparencyCommand.build(); } private static CheckTransparencyCommand fromFlagsInApkMode(ParsedFlags flags) { - CheckTransparencyCommand checkTransparencyCommand = + CheckTransparencyCommand.Builder checkTransparencyCommand = CheckTransparencyCommand.builder() .setMode(Mode.APK) - .setApkZipPath(APK_ZIP_LOCATION_FLAG.getRequiredValue(flags)) - .build(); + .setApkZipPath(APK_ZIP_LOCATION_FLAG.getRequiredValue(flags)); + TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG + .getValue(flags) + .ifPresent( + path -> + checkTransparencyCommand.setTransparencyKeyCertificate(getX509Certificate(path))); + APK_SIGNING_KEY_CERTIFICATE_LOCATION_FLAG + .getValue(flags) + .ifPresent( + path -> checkTransparencyCommand.setApkSigningKeyCertificate(getX509Certificate(path))); flags.checkNoUnknownFlags(); - return checkTransparencyCommand; + return checkTransparencyCommand.build(); } private static CheckTransparencyCommand fromFlagsInConnectedDeviceMode( ParsedFlags flags, SystemEnvironmentProvider systemEnvironmentProvider, AdbServer adbServer) { CheckTransparencyCommand.Builder checkTransparencyCommand = - CheckTransparencyCommand.builder().setMode(Mode.CONNECTED_DEVICE); + CheckTransparencyCommand.builder() + .setMode(Mode.CONNECTED_DEVICE) + .setPackageName(PACKAGE_NAME_FLAG.getRequiredValue(flags)); Optional deviceSerialName = DEVICE_ID_FLAG.getValue(flags); if (!deviceSerialName.isPresent()) { deviceSerialName = systemEnvironmentProvider.getVariable(ANDROID_SERIAL_VARIABLE); @@ -154,6 +197,15 @@ private static CheckTransparencyCommand fromFlagsInConnectedDeviceMode( Path adbPath = CommandUtils.getAdbPath(flags, ADB_PATH_FLAG, systemEnvironmentProvider); checkTransparencyCommand.setAdbPath(adbPath).setAdbServer(adbServer); + TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG + .getValue(flags) + .ifPresent( + path -> + checkTransparencyCommand.setTransparencyKeyCertificate(getX509Certificate(path))); + APK_SIGNING_KEY_CERTIFICATE_LOCATION_FLAG + .getValue(flags) + .ifPresent( + path -> checkTransparencyCommand.setApkSigningKeyCertificate(getX509Certificate(path))); flags.checkNoUnknownFlags(); return checkTransparencyCommand.build(); } @@ -164,17 +216,109 @@ public void execute() { } public void checkTransparency(PrintStream outputStream) { + TransparencyCheckResult result = TransparencyCheckResult.empty(); switch (getMode()) { case CONNECTED_DEVICE: - ConnectedDeviceTransparencyChecker.checkTransparency(this, outputStream); + result = ConnectedDeviceModeTransparencyChecker.checkTransparency(this); break; case BUNDLE: - BundleTransparencyChecker.checkTransparency(this, outputStream); + result = BundleModeTransparencyChecker.checkTransparency(this); break; case APK: - ApkTransparencyChecker.checkTransparency(this, outputStream); + result = ApkModeTransparencyChecker.checkTransparency(this); break; } + printResult(outputStream, result); + } + + private void printResult(PrintStream outputStream, TransparencyCheckResult result) { + boolean apkSignatureVerificationSuccess = verifyAndPrintApkSignatureCert(outputStream, result); + if (apkSignatureVerificationSuccess) { + printCodeTransparencyVerificationResult(outputStream, result); + } + } + + private boolean verifyAndPrintApkSignatureCert( + PrintStream outputStream, TransparencyCheckResult result) { + Preconditions.checkState( + getMode().equals(Mode.BUNDLE) + || !result.verified() + || result.apkSigningKeyCertificateFingerprint().isPresent(), + "APK signing key certificate fingerprint must be present in TransparencyCheckResult."); + if (getMode().equals(Mode.BUNDLE)) { + outputStream.println("No APK present. APK signature was not checked."); + return true; + } + if (!result.apkSigningKeyCertificateFingerprint().isPresent()) { + Preconditions.checkState( + !result.verified(), + "Successful TransparencyCheckResult must specify APK signing key certificate."); + outputStream.println(result.getErrorMessage()); + return false; + } + if (!getApkSigningKeyCertificate().isPresent()) { + outputStream.println( + "APK signature is valid. SHA-256 fingerprint of the apk signing key certificate (must be" + + " compared with the developer's public key manually): " + + result.getApkSigningKeyCertificateFingerprint()); + return true; + } + String providedApkSigningKeyCertificateFingerprint = + CodeTransparencyCryptoUtils.getCertificateFingerprint(getApkSigningKeyCertificate().get()); + if (result + .getApkSigningKeyCertificateFingerprint() + .equals(providedApkSigningKeyCertificateFingerprint)) { + outputStream.println("APK signature verified for the provided apk signing key certificate."); + return true; + } + outputStream.println( + "APK signature verification failed because the provided public key certificate does" + + " not match the APK signature." + + "\nSHA-256 fingerprint of the certificate that was used to sign the APKs: " + + result.getApkSigningKeyCertificateFingerprint() + + "\nSHA-256 fingerprint of the certificate that was provided: " + + providedApkSigningKeyCertificateFingerprint); + return false; + } + + private void printCodeTransparencyVerificationResult( + PrintStream outputStream, TransparencyCheckResult result) { + if (!result.verified()) { + outputStream.println(result.getErrorMessage()); + return; + } + if (!getTransparencyKeyCertificate().isPresent()) { + outputStream.println( + "Code transparency signature is valid. SHA-256 fingerprint of the code transparency key" + + " certificate (must be compared with the developer's public key manually): " + + result.getTransparencyKeyCertificateFingerprint()); + outputStream.println( + "Code transparency verified: code related file contents match the code transparency" + + " file."); + return; + } + String providedTransparencyKeyCertificateFingerprint = + CodeTransparencyCryptoUtils.getCertificateFingerprint( + getTransparencyKeyCertificate().get()); + if (result + .getTransparencyKeyCertificateFingerprint() + .equals(providedTransparencyKeyCertificateFingerprint)) { + outputStream.println( + "Code transparency signature verified for the provided code transparency key" + + " certificate."); + outputStream.println( + "Code transparency verified: code related file contents match the code transparency" + + " file."); + } else { + outputStream.println( + "Code transparency verification failed because the provided public key certificate does" + + " not match the code transparency file." + + "\nSHA-256 fingerprint of the certificate that was used to sign code transparency" + + " file: " + + result.getTransparencyKeyCertificateFingerprint() + + "\nSHA-256 fingerprint of the certificate that was provided: " + + providedTransparencyKeyCertificateFingerprint); + } } public static CommandHelp help() { @@ -223,6 +367,16 @@ public static CommandHelp help() { + " connected.", Mode.CONNECTED_DEVICE.getLowerCaseName(), ANDROID_SERIAL_VARIABLE) .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(PACKAGE_NAME_FLAG.getName()) + .setExampleValue("package-name") + .setOptional(true) + .setDescription( + "Package name of the app that code transparency will be verified for. Used" + + " only in '%s' mode.", + Mode.CONNECTED_DEVICE.getLowerCaseName()) + .build()) .addFlag( FlagDescription.builder() .setFlagName(BUNDLE_LOCATION_FLAG.getName()) @@ -243,6 +397,28 @@ public static CommandHelp help() { + " transparency for. Used only in '%s' mode. Must have extension .zip.", Mode.APK.getLowerCaseName()) .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(TRANSPARENCY_KEY_CERTIFICATE_LOCATION_FLAG.getName()) + .setExampleValue("path/to/certificate.cert") + .setOptional(true) + .setDescription( + "Path to the file containing the public key certificate that should be used" + + " for code transparency signature verification. If not set, fingerprint" + + " of the certificate that is used will be printed.") + .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(APK_SIGNING_KEY_CERTIFICATE_LOCATION_FLAG.getName()) + .setExampleValue("path/to/certificate.cert") + .setOptional(true) + .setDescription( + "Path to the file containing the public key certificate that should be used" + + " for APK signature verification. Can only be set in '%s and '%s' modes." + + " If not set, fingerprint of the certificate that is used will be" + + " printed.", + Mode.APK.getLowerCaseName(), Mode.CONNECTED_DEVICE.getLowerCaseName()) + .build()) .build(); } diff --git a/src/main/java/com/android/tools/build/bundletool/commands/ConnectedDeviceTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/commands/ConnectedDeviceTransparencyChecker.java deleted file mode 100644 index e98edac8..00000000 --- a/src/main/java/com/android/tools/build/bundletool/commands/ConnectedDeviceTransparencyChecker.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ -package com.android.tools.build.bundletool.commands; - -import java.io.PrintStream; - -/** Checks code transparency on a connected device. */ -final class ConnectedDeviceTransparencyChecker { - - static void checkTransparency(CheckTransparencyCommand command, PrintStream outputStream) { - outputStream.print("Not yet supported."); - } - - private ConnectedDeviceTransparencyChecker() {} -} diff --git a/src/main/java/com/android/tools/build/bundletool/commands/ExtractApksCommand.java b/src/main/java/com/android/tools/build/bundletool/commands/ExtractApksCommand.java index 4eee0992..1c65040f 100644 --- a/src/main/java/com/android/tools/build/bundletool/commands/ExtractApksCommand.java +++ b/src/main/java/com/android/tools/build/bundletool/commands/ExtractApksCommand.java @@ -16,6 +16,7 @@ package com.android.tools.build.bundletool.commands; +import static com.android.tools.build.bundletool.device.LocalTestingPathResolver.resolveLocalTestingPath; import static com.android.tools.build.bundletool.model.utils.files.FilePreconditions.checkDirectoryExists; import static com.android.tools.build.bundletool.model.utils.files.FilePreconditions.checkFileExistsAndReadable; import static com.google.common.base.Preconditions.checkArgument; @@ -31,6 +32,7 @@ import com.android.bundle.Commands.DefaultTargetingValue; import com.android.bundle.Commands.ExtractApksResult; import com.android.bundle.Commands.ExtractedApk; +import com.android.bundle.Commands.LocalTestingInfoForMetadata; import com.android.bundle.Config.SplitDimension.Value; import com.android.bundle.Devices.DeviceSpec; import com.android.tools.build.bundletool.commands.CommandHelp.CommandDescription; @@ -207,7 +209,7 @@ ImmutableList execute(PrintStream output) { .map(matchedApk -> getApksArchivePath().resolve(matchedApk.getPath().toString())) .collect(toImmutableList()); } else { - return extractMatchedApksFromApksArchive(generatedApks); + return extractMatchedApksFromApksArchive(generatedApks, toc); } } @@ -244,7 +246,7 @@ private void validateInput() { } private ImmutableList extractMatchedApksFromApksArchive( - ImmutableList generatedApks) { + ImmutableList generatedApks, BuildApksResult toc) { Path outputDirectoryPath = getOutputDirectory().orElseGet(ExtractApksCommand::createTempDirectory); @@ -274,7 +276,7 @@ private ImmutableList extractMatchedApksFromApksArchive( } } if (getIncludeMetadata()) { - produceCommandMetadata(generatedApks, outputDirectoryPath); + produceCommandMetadata(generatedApks, toc, outputDirectoryPath); } } catch (IOException e) { throw new UncheckedIOException( @@ -287,7 +289,8 @@ private ImmutableList extractMatchedApksFromApksArchive( } private static void produceCommandMetadata( - ImmutableList generatedApks, Path outputDir) { + ImmutableList generatedApks, BuildApksResult toc, Path outputDir) { + ImmutableList apks = generatedApks.stream() .map( @@ -301,13 +304,25 @@ private static void produceCommandMetadata( try { JsonFormat.Printer printer = JsonFormat.printer(); - String metadata = printer.print(ExtractApksResult.newBuilder().addAllApks(apks).build()); + ExtractApksResult.Builder builder = ExtractApksResult.newBuilder(); + if (toc.getLocalTestingInfo().getEnabled()) { + builder.setLocalTestingInfo(createLocalTestingInfo(toc)); + } + String metadata = printer.print(builder.addAllApks(apks).build()); Files.write(outputDir.resolve(METADATA_FILE), metadata.getBytes(UTF_8)); } catch (IOException e) { throw new UncheckedIOException("Error while writing metadata.json.", e); } } + private static LocalTestingInfoForMetadata createLocalTestingInfo(BuildApksResult toc) { + String localTestingPath = toc.getLocalTestingInfo().getLocalTestingPath(); + String packageName = toc.getPackageName(); + return LocalTestingInfoForMetadata.newBuilder() + .setLocalTestingDir(resolveLocalTestingPath(localTestingPath, Optional.of(packageName))) + .build(); + } + private static Path createTempDirectory() { try { return Files.createTempDirectory("bundletool-extracted-apks"); diff --git a/src/main/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommand.java b/src/main/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommand.java index 2cda1ebe..edcfa8d0 100644 --- a/src/main/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommand.java +++ b/src/main/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommand.java @@ -33,6 +33,7 @@ import com.android.tools.build.bundletool.model.utils.SystemEnvironmentProvider; import com.android.tools.build.bundletool.model.utils.files.FilePreconditions; import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableSet; import com.google.common.io.MoreFiles; import com.google.protobuf.util.JsonFormat; import java.io.IOException; @@ -55,6 +56,8 @@ public abstract class GetDeviceSpecCommand { private static final Flag OUTPUT_FLAG = Flag.path("output"); private static final Flag OVERWRITE_OUTPUT_FLAG = Flag.booleanFlag("overwrite"); private static final Flag DEVICE_TIER_FLAG = Flag.string("device-tier"); + private static final Flag> DEVICE_GROUPS_FLAG = + Flag.stringSet("device-groups"); private static final SystemEnvironmentProvider DEFAULT_PROVIDER = new DefaultSystemEnvironmentProvider(); @@ -73,6 +76,8 @@ public abstract class GetDeviceSpecCommand { public abstract Optional getDeviceTier(); + public abstract Optional> getDeviceGroups(); + public static Builder builder() { return new AutoValue_GetDeviceSpecCommand.Builder().setOverwriteOutput(false); } @@ -99,6 +104,8 @@ public abstract static class Builder { public abstract Builder setDeviceTier(String deviceTier); + public abstract Builder setDeviceGroups(ImmutableSet deviceGroups); + abstract GetDeviceSpecCommand autoBuild(); public GetDeviceSpecCommand build() { @@ -135,6 +142,7 @@ public static GetDeviceSpecCommand fromFlags( OVERWRITE_OUTPUT_FLAG.getValue(flags).ifPresent(builder::setOverwriteOutput); DEVICE_TIER_FLAG.getValue(flags).ifPresent(builder::setDeviceTier); + DEVICE_GROUPS_FLAG.getValue(flags).ifPresent(builder::setDeviceGroups); flags.checkNoUnknownFlags(); return builder.build(); @@ -154,6 +162,9 @@ public DeviceSpec execute() { if (getDeviceTier().isPresent()) { deviceSpec = deviceSpec.toBuilder().setDeviceTier(getDeviceTier().get()).build(); } + if (getDeviceGroups().isPresent()) { + deviceSpec = deviceSpec.toBuilder().addAllDeviceGroups(getDeviceGroups().get()).build(); + } writeDeviceSpecToFile(deviceSpec, getOutputPath()); return deviceSpec; } @@ -227,7 +238,20 @@ public static CommandHelp help() { .setOptional(true) .setDescription( "Device tier of the given device. This value will be used to match the correct" - + " device tier targeted APKs to this device.") + + " device tier targeted APKs to this device." + + " This flag is only relevant if the bundle uses device tier targeting," + + " and should be set in that case.") + .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(DEVICE_GROUPS_FLAG.getName()) + .setExampleValue("highRam,googlePixel") + .setOptional(true) + .setDescription( + "Device groups the given device belongs to. This value will be used to match" + + " the correct device group conditional modules to this device." + + " This flag is only relevant if the bundle uses device group targeting" + + " in conditional modules and should be set in that case.") .build()) .build(); } diff --git a/src/main/java/com/android/tools/build/bundletool/commands/InstallApksCommand.java b/src/main/java/com/android/tools/build/bundletool/commands/InstallApksCommand.java index 6ce83df2..3760a2d0 100644 --- a/src/main/java/com/android/tools/build/bundletool/commands/InstallApksCommand.java +++ b/src/main/java/com/android/tools/build/bundletool/commands/InstallApksCommand.java @@ -67,6 +67,8 @@ public abstract class InstallApksCommand { private static final Flag ALLOW_DOWNGRADE_FLAG = Flag.booleanFlag("allow-downgrade"); private static final Flag ALLOW_TEST_ONLY_FLAG = Flag.booleanFlag("allow-test-only"); private static final Flag DEVICE_TIER_FLAG = Flag.string("device-tier"); + private static final Flag> DEVICE_GROUPS_FLAG = + Flag.stringSet("device-groups"); private static final Flag> ADDITIONAL_LOCAL_TESTING_FILES_FLAG = Flag.pathList("additional-local-testing-files"); private static final Flag TIMEOUT_MILLIS_FLAG = Flag.positiveInteger("timeout-millis"); @@ -88,6 +90,8 @@ public abstract class InstallApksCommand { public abstract Optional getDeviceTier(); + public abstract Optional> getDeviceGroups(); + public abstract Optional> getAdditionalLocalTestingFiles(); abstract AdbServer getAdbServer(); @@ -121,6 +125,8 @@ public abstract static class Builder { public abstract Builder setDeviceTier(String deviceTier); + public abstract Builder setDeviceGroups(ImmutableSet deviceGroups); + public abstract Builder setAdditionalLocalTestingFiles(ImmutableList additionalFiles); public abstract Builder setTimeout(Duration timeout); @@ -144,6 +150,7 @@ public static InstallApksCommand fromFlags( Optional allowDowngrade = ALLOW_DOWNGRADE_FLAG.getValue(flags); Optional allowTestOnly = ALLOW_TEST_ONLY_FLAG.getValue(flags); Optional deviceTier = DEVICE_TIER_FLAG.getValue(flags); + Optional> deviceGroups = DEVICE_GROUPS_FLAG.getValue(flags); Optional> additionalLocalTestingFiles = ADDITIONAL_LOCAL_TESTING_FILES_FLAG.getValue(flags); Optional timeoutMillis = TIMEOUT_MILLIS_FLAG.getValue(flags); @@ -157,6 +164,7 @@ public static InstallApksCommand fromFlags( allowDowngrade.ifPresent(command::setAllowDowngrade); allowTestOnly.ifPresent(command::setAllowTestOnly); deviceTier.ifPresent(command::setDeviceTier); + deviceGroups.ifPresent(command::setDeviceGroups); additionalLocalTestingFiles.ifPresent(command::setAdditionalLocalTestingFiles); timeoutMillis.ifPresent(timeout -> command.setTimeout(Duration.ofMillis(timeout))); @@ -175,6 +183,9 @@ public void execute() { if (getDeviceTier().isPresent()) { deviceSpec = deviceSpec.toBuilder().setDeviceTier(getDeviceTier().get()).build(); } + if (getDeviceGroups().isPresent()) { + deviceSpec = deviceSpec.toBuilder().addAllDeviceGroups(getDeviceGroups().get()).build(); + } final ImmutableList apksToInstall = getApksToInstall(toc, deviceSpec, tempDirectory.getPath()); @@ -395,8 +406,21 @@ public static CommandHelp help() { .setExampleValue("high") .setOptional(true) .setDescription( - "Device tier to use for apk matching. This flag is only relevant if the " - + "bundle uses device tier targeting, and should be set in that case.") + "Device tier of the given device. This value will be used to match the correct" + + " device tier targeted APKs to this device." + + " This flag is only relevant if the bundle uses device tier targeting," + + " and should be set in that case.") + .build()) + .addFlag( + FlagDescription.builder() + .setFlagName(DEVICE_GROUPS_FLAG.getName()) + .setExampleValue("highRam,googlePixel") + .setOptional(true) + .setDescription( + "Device groups the given device belongs to. This value will be used to match" + + " the correct device group conditional modules to this device." + + " This flag is only relevant if the bundle uses device group targeting" + + " in conditional modules and should be set in that case.") .build()) .addFlag( FlagDescription.builder() diff --git a/src/main/java/com/android/tools/build/bundletool/device/ApkMatcher.java b/src/main/java/com/android/tools/build/bundletool/device/ApkMatcher.java index 97ad461e..ac9cb23d 100644 --- a/src/main/java/com/android/tools/build/bundletool/device/ApkMatcher.java +++ b/src/main/java/com/android/tools/build/bundletool/device/ApkMatcher.java @@ -94,7 +94,7 @@ public ApkMatcher( TextureCompressionFormatMatcher textureCompressionFormatMatcher = new TextureCompressionFormatMatcher(deviceSpec); DeviceTierApkMatcher deviceTierApkMatcher = new DeviceTierApkMatcher(deviceSpec); - DeviceTierModuleMatcher deviceTierModuleMatcher = new DeviceTierModuleMatcher(deviceSpec); + DeviceGroupModuleMatcher deviceGroupModuleMatcher = new DeviceGroupModuleMatcher(deviceSpec); this.apkMatchers = ImmutableList.of( @@ -110,7 +110,10 @@ public ApkMatcher( this.ensureDensityAndAbiApksMatched = ensureDensityAndAbiApksMatched; this.moduleMatcher = new ModuleMatcher( - sdkVersionMatcher, deviceFeatureMatcher, openGlFeatureMatcher, deviceTierModuleMatcher); + sdkVersionMatcher, + deviceFeatureMatcher, + openGlFeatureMatcher, + deviceGroupModuleMatcher); this.variantMatcher = new VariantMatcher( sdkVersionMatcher, diff --git a/src/main/java/com/android/tools/build/bundletool/device/DdmlibDevice.java b/src/main/java/com/android/tools/build/bundletool/device/DdmlibDevice.java index f6834f07..9a19776a 100644 --- a/src/main/java/com/android/tools/build/bundletool/device/DdmlibDevice.java +++ b/src/main/java/com/android/tools/build/bundletool/device/DdmlibDevice.java @@ -16,6 +16,7 @@ package com.android.tools.build.bundletool.device; +import static com.android.tools.build.bundletool.device.LocalTestingPathResolver.resolveLocalTestingPath; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.ImmutableList.toImmutableList; @@ -164,21 +165,7 @@ public void push(ImmutableList files, PushOptions pushOptions) { new RemoteCommandExecutor(this, pushOptions.getTimeout().toMillis(), System.err); DdmPreferences.setTimeOut((int) pushOptions.getTimeout().toMillis()); try { - // There are two different flows, depending on if the path is absolute or not... - if (!splitsPath.startsWith("/")) { - // Path is relative, so we're going to try to push it to the app's external dir - String packageName = - pushOptions - .getPackageName() - .orElseThrow( - () -> - CommandExecutionException.builder() - .withInternalMessage( - "PushOptions.packageName must be set for relative paths.") - .build()); - - splitsPath = joinUnixPaths("/sdcard/Android/data/", packageName, "files", splitsPath); - } + splitsPath = resolveLocalTestingPath(splitsPath, pushOptions.getPackageName()); // Now the path is absolute. We assume it's pointing to a location writeable by ADB shell. // It shouldn't point to app's private directory. @@ -250,6 +237,20 @@ public void removeRemotePackage(Path remoteFilePath) throws InstallException { device.removeRemotePackage(remoteFilePath.toString()); } + @Override + public void pull(ImmutableList files) { + files.forEach(file -> pullFile(file.getPathOnDevice(), file.getDestinationPath().toString())); + } + + private void pullFile(String pathOnDevice, String destinationPath) { + try { + device.pullFile(pathOnDevice, destinationPath); + } catch (IOException | AdbCommandRejectedException | TimeoutException | SyncException e) { + throw new CommandExecutionException( + "Exception while pulling file from the device.", e.getMessage(), e.getCause()); + } + } + static class RemoteCommandExecutor { private final Device device; private final MultiLineReceiver receiver; diff --git a/src/main/java/com/android/tools/build/bundletool/device/Device.java b/src/main/java/com/android/tools/build/bundletool/device/Device.java index 56e337a6..322f0fd5 100644 --- a/src/main/java/com/android/tools/build/bundletool/device/Device.java +++ b/src/main/java/com/android/tools/build/bundletool/device/Device.java @@ -72,6 +72,8 @@ public abstract Path syncPackageToDevice(Path localFilePath) public abstract void removeRemotePackage(Path remoteFilePath) throws InstallException; + public abstract void pull(ImmutableList files); + /** Options related to APK installation. */ @Immutable @AutoValue @@ -142,4 +144,30 @@ public abstract static class Builder { public abstract PushOptions build(); } } + + /** Parameters related to pulling a single file from the device. */ + @Immutable + @AutoValue + @AutoValue.CopyAnnotations + public abstract static class FilePullParams { + /** Path to the remote file that should be pulled from the device. */ + public abstract String getPathOnDevice(); + + /** Path to the local file where the pulled file should be written to. */ + public abstract Path getDestinationPath(); + + public static Builder builder() { + return new AutoValue_Device_FilePullParams.Builder(); + } + + /** Builder for {@link FilePullParams}. */ + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setPathOnDevice(String pathOnDevice); + + public abstract Builder setDestinationPath(Path destinationPath); + + public abstract FilePullParams build(); + } + } } diff --git a/src/main/java/com/android/tools/build/bundletool/device/DeviceTierModuleMatcher.java b/src/main/java/com/android/tools/build/bundletool/device/DeviceGroupModuleMatcher.java similarity index 53% rename from src/main/java/com/android/tools/build/bundletool/device/DeviceTierModuleMatcher.java rename to src/main/java/com/android/tools/build/bundletool/device/DeviceGroupModuleMatcher.java index b49b3aa4..c7e31142 100644 --- a/src/main/java/com/android/tools/build/bundletool/device/DeviceTierModuleMatcher.java +++ b/src/main/java/com/android/tools/build/bundletool/device/DeviceGroupModuleMatcher.java @@ -16,26 +16,30 @@ package com.android.tools.build.bundletool.device; import com.android.bundle.Devices.DeviceSpec; -import com.android.bundle.Targeting.DeviceTierModuleTargeting; +import com.android.bundle.Targeting.DeviceGroupModuleTargeting; import com.android.bundle.Targeting.ModuleTargeting; +import java.util.Collections; /** - * A {@link TargetingDimensionMatcher} that provides module matching on device tier. + * A {@link TargetingDimensionMatcher} that provides module matching on a set of device groups. * - *

Device tier is an artificial concept and it is explicitly defined in the {@link DeviceSpec}. + *

Device groups are an artificial concept and they are explicitly defined in the {@link + * DeviceSpec}. */ -public class DeviceTierModuleMatcher extends TargetingDimensionMatcher { +public class DeviceGroupModuleMatcher + extends TargetingDimensionMatcher { - public DeviceTierModuleMatcher(DeviceSpec deviceSpec) { + public DeviceGroupModuleMatcher(DeviceSpec deviceSpec) { super(deviceSpec); } @Override - public boolean matchesTargeting(DeviceTierModuleTargeting targetingValue) { - if (targetingValue.equals(DeviceTierModuleTargeting.getDefaultInstance())) { + public boolean matchesTargeting(DeviceGroupModuleTargeting targetingValue) { + if (targetingValue.equals(DeviceGroupModuleTargeting.getDefaultInstance())) { return true; } - return targetingValue.getValueList().contains(getDeviceSpec().getDeviceTier()); + return !Collections.disjoint( + targetingValue.getValueList(), getDeviceSpec().getDeviceGroupsList()); } @Override @@ -44,10 +48,10 @@ protected boolean isDeviceDimensionPresent() { } @Override - protected void checkDeviceCompatibleInternal(DeviceTierModuleTargeting targetingValue) {} + protected void checkDeviceCompatibleInternal(DeviceGroupModuleTargeting targetingValue) {} @Override - protected DeviceTierModuleTargeting getTargetingValue(ModuleTargeting moduleTargeting) { - return moduleTargeting.getDeviceTierTargeting(); + protected DeviceGroupModuleTargeting getTargetingValue(ModuleTargeting moduleTargeting) { + return moduleTargeting.getDeviceGroupTargeting(); } } diff --git a/src/main/java/com/android/tools/build/bundletool/device/LocalTestingPathResolver.java b/src/main/java/com/android/tools/build/bundletool/device/LocalTestingPathResolver.java new file mode 100644 index 00000000..c5dfc840 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/device/LocalTestingPathResolver.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tools.build.bundletool.device; + +import static com.android.tools.build.bundletool.device.DdmlibDevice.joinUnixPaths; + +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; +import java.util.Optional; + +/** Resolves a local testing directory to a path on the device in the apps external storage. */ +public class LocalTestingPathResolver { + + private LocalTestingPathResolver() {} + + public static String resolveLocalTestingPath(String localTestPath, Optional packageName) { + // There are two different flows, depending on if the path is absolute or not... + if (localTestPath.startsWith("/")) { + return localTestPath; + } + // Path is relative, so we're going to try to push it to the app's external dir + String packageNameStr = + packageName.orElseThrow( + () -> + CommandExecutionException.builder() + .withInternalMessage("packageName must be set for relative paths.") + .build()); + return joinUnixPaths("/sdcard/Android/data/", packageNameStr, "files", localTestPath); + } +} diff --git a/src/main/java/com/android/tools/build/bundletool/device/ModuleMatcher.java b/src/main/java/com/android/tools/build/bundletool/device/ModuleMatcher.java index 7eed603a..a1473d52 100644 --- a/src/main/java/com/android/tools/build/bundletool/device/ModuleMatcher.java +++ b/src/main/java/com/android/tools/build/bundletool/device/ModuleMatcher.java @@ -34,10 +34,13 @@ public ModuleMatcher( SdkVersionMatcher sdkVersionMatcher, DeviceFeatureMatcher deviceFeatureMatcher, OpenGlFeatureMatcher openGlFeatureMatcher, - DeviceTierModuleMatcher deviceTierModuleMatcher) { + DeviceGroupModuleMatcher deviceGroupModuleMatcher) { this.moduleMatchers = ImmutableList.of( - sdkVersionMatcher, deviceFeatureMatcher, openGlFeatureMatcher, deviceTierModuleMatcher); + sdkVersionMatcher, + deviceFeatureMatcher, + openGlFeatureMatcher, + deviceGroupModuleMatcher); } @VisibleForTesting @@ -46,7 +49,7 @@ public ModuleMatcher(DeviceSpec deviceSpec) { new SdkVersionMatcher(deviceSpec), new DeviceFeatureMatcher(deviceSpec), new OpenGlFeatureMatcher(deviceSpec), - new DeviceTierModuleMatcher(deviceSpec)); + new DeviceGroupModuleMatcher(deviceSpec)); } /** diff --git a/src/main/java/com/android/tools/build/bundletool/io/ApkzlibApkSerializerHelper.java b/src/main/java/com/android/tools/build/bundletool/io/ApkzlibApkSerializerHelper.java index 34f13c61..cbc18e26 100644 --- a/src/main/java/com/android/tools/build/bundletool/io/ApkzlibApkSerializerHelper.java +++ b/src/main/java/com/android/tools/build/bundletool/io/ApkzlibApkSerializerHelper.java @@ -38,6 +38,7 @@ import com.android.tools.build.bundletool.model.utils.PathMatcher; import com.android.tools.build.bundletool.model.utils.files.FileUtils; import com.android.tools.build.bundletool.model.version.Version; +import com.google.common.base.Optional; import com.google.common.base.Predicates; import com.google.common.collect.ImmutableList; import java.io.IOException; @@ -73,9 +74,6 @@ final class ApkzlibApkSerializerHelper extends ApkSerializerHelper { AlignmentRules.constantForSuffix(NATIVE_LIBRARIES_SUFFIX, 4096), AlignmentRules.constant(4)); - private static final String BUILT_BY = "BundleTool"; - private static final String CREATED_BY = BUILT_BY; - private final Aapt2Command aapt2Command; private final Version bundletoolVersion; private final ImmutableList uncompressedPathMatchers; @@ -146,9 +144,11 @@ private void writeToZipFile(ModuleSplit split, Path outputPath, TempDirectory te .setCoverEmptySpaceUsingExtraField(true) // Clear timestamps on zip entries to minimize diffs between APKs. .setNoTimestamps(true), - /* signingOptions= */ com.google.common.base.Optional.absent(), - BUILT_BY, - CREATED_BY); + /* signingOptions= */ Optional.absent(), + /* builtBy= */ null, + /* createdBy= */ null, + // Use ZFileOptions.setAlwaysGenerateJarManifest(false) when this is released. + /* writeManifest= */ false); ZFile zAapt2Files = ZFile.openReadOnly(binaryApk.toFile(), createZFileOptions(tempDir.getPath()))) { @@ -207,14 +207,21 @@ private Path writeProtoApk(ModuleSplit split, Path outputPath) throws IOExceptio private EntryOption[] entryOptionForPath( ZipPath path, boolean uncompressNativeLibs, boolean forceUncompressed) { - if (shouldCompress(path, uncompressNativeLibs, forceUncompressed)) { + if (mayCompress(path, uncompressNativeLibs, forceUncompressed)) { return new EntryOption[] {}; } else { return new EntryOption[] {EntryOption.UNCOMPRESSED}; } } - private boolean shouldCompress( + /** + * Returns true if the specified file may be compressed in the final generated APK. + * + *

If this method returns true, the preference is that the file is compressed within the APK, + * however this isn't guaranteed, e.g. if the file's compressed size is greater than the + * uncompressed size. If this method returns false, the file must be stored uncompressed. + */ + private boolean mayCompress( ZipPath path, boolean uncompressNativeLibs, boolean forceUncompressed) { if (uncompressedPathMatchers.stream() .anyMatch(pathMatcher -> pathMatcher.matches(path.toString()))) { @@ -237,7 +244,7 @@ private boolean shouldCompress( return false; } - // By default, compressed. + // By default, may be compressed. return true; } @@ -249,17 +256,17 @@ private void addNonAapt2Files(ZFile zFile, ModuleSplit split) throws IOException for (ModuleEntry entry : split.getEntries()) { ZipPath pathInApk = toApkEntryPath(entry.getPath()); if (!requiresAapt2Conversion(pathInApk)) { - boolean shouldCompress = - shouldCompress(pathInApk, !extractNativeLibs, entry.getForceUncompressed()); - addFile(zFile, pathInApk, entry, shouldCompress); + boolean mayCompress = + mayCompress(pathInApk, !extractNativeLibs, entry.getForceUncompressed()); + addFile(zFile, pathInApk, entry, mayCompress); } } } - void addFile(ZFile zFile, ZipPath pathInApk, ModuleEntry entry, boolean shouldCompress) + void addFile(ZFile zFile, ZipPath pathInApk, ModuleEntry entry, boolean mayCompress) throws IOException { try (InputStream entryInputStream = entry.getContent().openStream()) { - zFile.add(pathInApk.toString(), entryInputStream, shouldCompress); + zFile.add(pathInApk.toString(), entryInputStream, mayCompress); } } diff --git a/src/main/java/com/android/tools/build/bundletool/io/BytesSource2.java b/src/main/java/com/android/tools/build/bundletool/io/BytesSource2.java new file mode 100644 index 00000000..0bb857d4 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/io/BytesSource2.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.io; + +import com.android.zipflinger.BytesSource; +import java.io.IOException; + +/** + * Same as {@link BytesSource} but exposes the (un)compressed size to this package. + * + *

This class can be deleted once bundletool depends on the version of zipflinger that makes + * these methods public in {@link BytesSource}. + */ +final class BytesSource2 extends BytesSource { + + public BytesSource2(byte[] bytes, String name, int compressionLevel) throws IOException { + super(bytes, name, compressionLevel); + } + + /** + * Returns the entry's compressed size. + * + *

The name of the method is suffixed with "2" to avoid confusing the compiler with the method + * with the same name in the {@link BytesSource} class. + */ + public long getCompressedSize2() { + return compressedSize; + } + + /** + * Returns the entry's uncompressed size. + * + *

The name of the method is suffixed with "2" to avoid confusing the compiler with the method + * with the same name in the {@link BytesSource} class. + */ + public long getUncompressedSize2() { + return uncompressedSize; + } +} diff --git a/src/main/java/com/android/tools/build/bundletool/io/ZipFlingerApkSerializerHelper.java b/src/main/java/com/android/tools/build/bundletool/io/ZipFlingerApkSerializerHelper.java index d67cbf4b..2978c292 100644 --- a/src/main/java/com/android/tools/build/bundletool/io/ZipFlingerApkSerializerHelper.java +++ b/src/main/java/com/android/tools/build/bundletool/io/ZipFlingerApkSerializerHelper.java @@ -27,8 +27,10 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.google.common.collect.ImmutableSet.toImmutableSet; -import static java.util.Comparator.comparing; +import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet; +import static java.util.Comparator.naturalOrder; import com.android.bundle.Config.BundleConfig; import com.android.bundle.Config.ResourceOptimizations.SparseEncoding; @@ -48,12 +50,15 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Optional; import java.util.regex.Pattern; +import java.util.stream.Stream; import javax.inject.Inject; /** Serializes APKs to Proto or Binary format. */ @@ -133,69 +138,65 @@ private void writeToZipFile(ModuleSplit split, Path outputPath, TempDirectory te } checkState(Files.exists(binaryApkPath), "No APK created by aapt2 convert command."); - CompressionManager compressionManager = new CompressionManager(split, bundleConfig); - - try (ZipArchive apkWriter = new ZipArchive(outputPath.toFile())) { - addEntriesConvertedByAapt2(apkWriter, binaryApkPath, compressionManager, tempDir); - addRemainingEntries(apkWriter, split, compressionManager, tempDir); - } - - apkSigner.signApk(outputPath, split); - } + try (ZipArchive apkWriter = new ZipArchive(outputPath); + ZipReader aapt2ApkReader = ZipReader.createFromFile(binaryApkPath)) { + ImmutableMap moduleEntriesByName = + split.getEntries().stream() + .collect( + toImmutableMap( + entry -> ApkSerializerHelper.toApkEntryPath(entry.getPath()), + entry -> entry, + // If two entries end up at the same path in the APK, pick one arbitrarily. + // e.g. base/assets/foo and base/root/assets/foo. + (a, b) -> b)); - @SuppressWarnings("MethodCanBeStatic") - private void addEntriesConvertedByAapt2( - ZipArchive apkWriter, - Path binaryApkPath, - CompressionManager compressionManager, - TempDirectory tempDir) - throws IOException { - try (ZipReader aapt2ApkZipReader = ZipReader.createFromFile(binaryApkPath)) { // Sorting entries by name for determinism. - ImmutableList sortedAapt2ApkEntries = - ImmutableList.sortedCopyOf( - comparing(Entry::getName), aapt2ApkZipReader.getEntries().values()); - - ZipEntrySourceFactory sourceFactory = new ZipEntrySourceFactory(aapt2ApkZipReader, tempDir); - - for (Entry entry : sortedAapt2ApkEntries) { - String entryName = entry.getName(); - ZipPath pathInApk = ZipPath.create(entryName); - CompressionLevel compressionLevel; - if (compressionManager.shouldCompress(pathInApk)) { - compressionLevel = entry.isCompressed() ? SAME_AS_SOURCE : BEST_COMPRESSION; + ImmutableSortedSet sortedEntryNames = + Stream.concat( + aapt2ApkReader.getEntries().keySet().stream().map(ZipPath::create), + moduleEntriesByName.keySet().stream()) + .collect(toImmutableSortedSet(naturalOrder())); + + ApkEntrySerializer apkEntrySerializer = + new ApkEntrySerializer(apkWriter, aapt2ApkReader, split, tempDir); + for (ZipPath pathInApk : sortedEntryNames) { + Optional aapt2Entry = aapt2ApkReader.getEntry(pathInApk.toString()); + if (aapt2Entry.isPresent()) { + apkEntrySerializer.addAapt2Entry(pathInApk, aapt2Entry.get()); } else { - compressionLevel = NO_COMPRESSION; + ModuleEntry moduleEntry = checkNotNull(moduleEntriesByName.get(pathInApk)); + apkEntrySerializer.addRegularEntry(pathInApk, moduleEntry); } - apkWriter.add( - sourceFactory - .create(entry, pathInApk, compressionLevel) - .setAlignment(getEntryAlignment(pathInApk, entry.isCompressed()))); } } + + apkSigner.signApk(outputPath, split); } - private void addRemainingEntries( - ZipArchive apkWriter, - ModuleSplit split, - CompressionManager compressionManager, - TempDirectory tempDir) - throws IOException { - // Sort entries by name for determinism. - ImmutableList sortedEntries = - ImmutableList.sortedCopyOf( - comparing(entry -> ApkSerializerHelper.toApkEntryPath(entry.getPath())), - split.getEntries()); - - ZipEntrySourceFactory sourceFactory = new ZipEntrySourceFactory(bundleZipReader, tempDir); - ImmutableMap bundleEntries = bundleZipReader.getEntries(); - - for (ModuleEntry moduleEntry : sortedEntries) { - ZipPath pathInApk = ApkSerializerHelper.toApkEntryPath(moduleEntry.getPath()); - if (requiresAapt2Conversion(pathInApk)) { - continue; - } - boolean shouldCompress = compressionManager.shouldCompress(pathInApk); + private final class ApkEntrySerializer { + /** Output APK where the entries will be added to. */ + private final ZipArchive apkWriter; + /** The APK generated by aapt2 containing resources, manifest, etc. */ + private final ZipReader aapt2Apk; + /** A directory to store intermediate artifacts. */ + private final TempDirectory tempDir; + /** Controller of the compression of the entries in the final APK. */ + private final CompressionManager compressionManager; + /** A map of the entries from the app bundle to copy data from. */ + private final ImmutableMap bundleEntries; + + ApkEntrySerializer( + ZipArchive apkWriter, ZipReader aapt2Apk, ModuleSplit moduleSplit, TempDirectory tempDir) { + this.apkWriter = apkWriter; + this.aapt2Apk = aapt2Apk; + this.tempDir = tempDir; + this.compressionManager = new CompressionManager(moduleSplit, bundleConfig); + this.bundleEntries = bundleZipReader.getEntries(); + } + + void addRegularEntry(ZipPath pathInApk, ModuleEntry moduleEntry) throws IOException { + ZipEntrySourceFactory sourceFactory = new ZipEntrySourceFactory(bundleZipReader, tempDir); + boolean mayCompress = compressionManager.mayCompress(pathInApk); if (moduleEntry.getBundleLocation().isPresent()) { ZipPath pathInBundle = moduleEntry.getBundleLocation().get().entryPathInBundle(); @@ -203,26 +204,70 @@ private void addRemainingEntries( checkNotNull(entry, "Could not find entry '%s'.", pathInBundle); CompressionLevel compressionLevel; - if (shouldCompress) { + if (mayCompress) { compressionLevel = useBundleCompression && entry.isCompressed() ? SAME_AS_SOURCE : DEFAULT_COMPRESSION; } else { compressionLevel = NO_COMPRESSION; } - apkWriter.add( + ZipEntrySource entrySource = sourceFactory .create(entry, pathInApk, compressionLevel) - .setAlignment(getEntryAlignment(pathInApk, shouldCompress))); + .setAlignment(getEntryAlignment(pathInApk, mayCompress)); + if (compressionLevel.isCompressed() + && entrySource.getCompressedSize() >= entrySource.getUncompressedSize()) { + // Not enough gains from compression, leave the entry uncompressed. + entrySource = + sourceFactory + .create(entry, pathInApk, NO_COMPRESSION) + .setAlignment(getEntryAlignment(pathInApk, /* compressed= */ false)); + } + apkWriter.add(entrySource); + } else { - BytesSource bytesSource = - new BytesSource( - moduleEntry.getContent().read(), + byte[] uncompressedContent = moduleEntry.getContent().read(); + BytesSource2 bytesSource = + new BytesSource2( + uncompressedContent, pathInApk.toString(), - shouldCompress ? DEFAULT_COMPRESSION.getValue() : NO_COMPRESSION.getValue()); - bytesSource.align(getEntryAlignment(pathInApk, shouldCompress)); + mayCompress ? DEFAULT_COMPRESSION.getValue() : NO_COMPRESSION.getValue()); + bytesSource.align(getEntryAlignment(pathInApk, mayCompress)); + if (mayCompress && bytesSource.getCompressedSize2() >= bytesSource.getUncompressedSize2()) { + // Not enough gains from compression, leave the entry uncompressed. + bytesSource = + new BytesSource2( + uncompressedContent, pathInApk.toString(), NO_COMPRESSION.getValue()); + bytesSource.align(getEntryAlignment(pathInApk, /* compressed= */ false)); + } + apkWriter.add(bytesSource); } } + + void addAapt2Entry(ZipPath pathInApk, Entry entry) throws IOException { + ZipEntrySourceFactory sourceFactory = new ZipEntrySourceFactory(aapt2Apk, tempDir); + boolean mayCompress = compressionManager.mayCompress(pathInApk); + + // All entries in aapt2 should be uncompressed (see AppBundleRecompressor), so we don't use + // SAME_AS_SOURCE. + CompressionLevel compressionLevel = mayCompress ? BEST_COMPRESSION : NO_COMPRESSION; + ZipEntrySource entrySource = + sourceFactory + .create(entry, pathInApk, compressionLevel) + .setAlignment(getEntryAlignment(pathInApk, mayCompress)); + // Copying logic from aapt2: require at least 10% gains in savings. + if (!compressionLevel.equals(NO_COMPRESSION) + && (entrySource.getCompressedSize() + entrySource.getCompressedSize() / 10) + > entrySource.getUncompressedSize()) { + // Not enough gains from compression, leave the entry uncompressed. + entrySource = + sourceFactory + .create(entry, pathInApk, NO_COMPRESSION) + .setAlignment(getEntryAlignment(pathInApk, /* compressed= */ false)); + } + + apkWriter.add(entrySource); + } } /** @@ -240,7 +285,7 @@ private void addRemainingEntries( */ private void writeProtoApk(ModuleSplit split, Path protoApkPath, TempDirectory tempDir) throws IOException { - try (ZipArchive apkWriter = new ZipArchive(protoApkPath.toFile())) { + try (ZipArchive apkWriter = new ZipArchive(protoApkPath)) { apkWriter.add( new BytesSource( split.getAndroidManifest().getManifestRoot().getProto().toByteArray(), @@ -308,14 +353,29 @@ private class CompressionManager { .collect(toImmutableList()); } - public boolean shouldCompress(ZipPath path) { - if (uncompressedPathMatchers.stream() - .anyMatch(pathMatcher -> pathMatcher.matches(path.toString()))) { + /** + * Returns true if the specified file may be compressed in the final generated APK. + * + *

If this method returns true, the preference is that the file is compressed within the APK, + * however this isn't guaranteed, e.g. if the file's compressed size is greater than the + * uncompressed size. If this method returns false, the file must be stored uncompressed. + */ + public boolean mayCompress(ZipPath path) { + String pathString = path.toString(); + + // Resource table should always be uncompressed for runtime performance reasons. + if (pathString.equals("resources.arsc")) { return false; } - // Resource table should always be uncompressed for runtime performance reasons. - if (path.toString().equals("resources.arsc")) { + // The AndroidManifest.xml should be compressed even if *.xml files are set to be uncompressed + // in the BundleConfig. + if (pathString.equals("AndroidManifest.xml")) { + return true; + } + + if (uncompressedPathMatchers.stream() + .anyMatch(pathMatcher -> pathMatcher.matches(pathString))) { return false; } @@ -331,11 +391,11 @@ public boolean shouldCompress(ZipPath path) { } // Uncompressed native libraries (supported since SDK 23 - Android M). - if (uncompressNativeLibs && NATIVE_LIBRARIES_PATTERN.matcher(path.toString()).matches()) { + if (uncompressNativeLibs && NATIVE_LIBRARIES_PATTERN.matcher(pathString).matches()) { return false; } - // By default, compressed. + // By default, may be compressed. return true; } } diff --git a/src/main/java/com/android/tools/build/bundletool/mergers/D8DexMerger.java b/src/main/java/com/android/tools/build/bundletool/mergers/D8DexMerger.java index 0de98103..c98c852f 100644 --- a/src/main/java/com/android/tools/build/bundletool/mergers/D8DexMerger.java +++ b/src/main/java/com/android/tools/build/bundletool/mergers/D8DexMerger.java @@ -26,9 +26,17 @@ import com.android.tools.r8.CompilationMode; import com.android.tools.r8.D8; import com.android.tools.r8.D8Command; +import com.android.tools.r8.DexIndexedConsumer.ForwardingConsumer; +import com.android.tools.r8.Diagnostic; +import com.android.tools.r8.DiagnosticsHandler; import com.android.tools.r8.OutputMode; +import com.google.common.base.Throwables; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Streams; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Optional; @@ -36,7 +44,9 @@ /** Merges dex files using D8. */ public class D8DexMerger implements DexMerger { - + private static final String CORE_DESUGARING_PREFIX = "j$."; + private static final String CORE_DESUGARING_LIBRARY_EXCEPTION = + "Merging dex file containing classes with prefix 'j$.'"; private static final String DEX_OVERFLOW_MSG = "Cannot fit requested classes in a single dex file"; @@ -59,7 +69,18 @@ public ImmutableList merge( // however we are merging existing dex files. The parameters considered are: // - classpathFiles, libraryFiles: Required for desugaring during compilation. D8Command.Builder command = - D8Command.builder() + D8Command.builder( + new DiagnosticsHandler() { + @Override + public void error(Diagnostic error) { + if (error + .getDiagnosticMessage() + .contains(CORE_DESUGARING_LIBRARY_EXCEPTION)) { + return; + } + DiagnosticsHandler.super.error(error); + } + }) .setOutput(outputDir, OutputMode.DexIndexed) .addProgramFiles(dexFiles) .setMinApiLevel(minSdkVersion) @@ -79,15 +100,16 @@ public ImmutableList merge( return Arrays.stream(mergedFiles).map(File::toPath).collect(toImmutableList()); } catch (CompilationFailedException e) { + if (isCoreDesugaringException(e)) { + // If merge fails because of core desugaring library, exclude dex files related to core + // desugaring lib and try again. + return mergeAppDexFilesAndRenameCoreDesugaringDex( + dexFiles, outputDir, mainDexListFile, proguardMap, isDebuggable, minSdkVersion); + } if (proguardMap.isPresent()) { // Try without the proguard map return merge( - dexFiles, - outputDir, - mainDexListFile, - Optional.empty(), - isDebuggable, - minSdkVersion); + dexFiles, outputDir, mainDexListFile, Optional.empty(), isDebuggable, minSdkVersion); } else { throw translateD8Exception(e); } @@ -113,10 +135,84 @@ private static CommandExecutionException translateD8Exception( .withCause(d8Exception) .build(); } else { + Throwable rootCause = Throwables.getRootCause(d8Exception); return CommandExecutionException.builder() - .withInternalMessage("Dex merging failed.") + .withInternalMessage("Dex merging failed. %s", rootCause.getMessage()) .withCause(d8Exception) .build(); } } + + private static boolean isCoreDesugaringException(CompilationFailedException d8Exception) { + return ThrowableUtils.anyInCausalChainOrSuppressedMatches( + d8Exception, + t -> t.getMessage() != null && t.getMessage().contains(CORE_DESUGARING_LIBRARY_EXCEPTION)); + } + + private ImmutableList mergeAppDexFilesAndRenameCoreDesugaringDex( + ImmutableList dexFiles, + Path outputDir, + Optional mainDexListFile, + Optional proguardMap, + boolean isDebuggable, + int minSdkVersion) { + ImmutableList desugaringDexFiles = + dexFiles.stream().filter(D8DexMerger::isCoreDesugaringDex).collect(toImmutableList()); + ImmutableList appDexFiles = + dexFiles.stream() + .filter(dex -> !desugaringDexFiles.contains(dex)) + .collect(toImmutableList()); + + ImmutableList mergedAppDexFiles = + merge(appDexFiles, outputDir, mainDexListFile, proguardMap, isDebuggable, minSdkVersion); + ImmutableList mergedDesugaringDexFiles = + Streams.mapWithIndex( + desugaringDexFiles.stream(), + (dex, index) -> + copyDexToOutput(dex, outputDir, (int) index + 1 + mergedAppDexFiles.size())) + .collect(toImmutableList()); + + return ImmutableList.builder() + .addAll(mergedAppDexFiles) + .addAll(mergedDesugaringDexFiles) + .build(); + } + + private static Path copyDexToOutput(Path input, Path outputDir, int index) { + String outputName = index == 1 ? "classes.dex" : String.format("classes%d.dex", index); + Path output = outputDir.resolve(outputName); + try { + Files.copy(input, output); + return output; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static boolean isCoreDesugaringDex(Path dexFile) { + try { + boolean[] isDesugaringDex = new boolean[] {true}; + D8Command.Builder builder = + D8Command.builder() + .addProgramFiles(dexFile) + .setProgramConsumer(new ForwardingConsumer(null)); + builder.addOutputInspection( + inspection -> + inspection.forEachClass( + clazz -> + isDesugaringDex[0] = + isDesugaringDex[0] + && clazz + .getClassReference() + .getTypeName() + .startsWith(CORE_DESUGARING_PREFIX))); + D8.run(builder.build()); + return isDesugaringDex[0]; + } catch (CompilationFailedException e) { + throw CommandExecutionException.builder() + .withInternalMessage("Failed to read dex file %s.", dexFile.getFileName().toString()) + .withCause(e) + .build(); + } + } } diff --git a/src/main/java/com/android/tools/build/bundletool/model/AndroidManifest.java b/src/main/java/com/android/tools/build/bundletool/model/AndroidManifest.java index c6fa7922..77963358 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/AndroidManifest.java +++ b/src/main/java/com/android/tools/build/bundletool/model/AndroidManifest.java @@ -90,8 +90,8 @@ public abstract class AndroidManifest { public static final String CONDITION_MIN_SDK_VERSION_NAME = "min-sdk"; public static final String CONDITION_MAX_SDK_VERSION_NAME = "max-sdk"; public static final String CONDITION_USER_COUNTRIES_NAME = "user-countries"; - public static final String CONDITION_DEVICE_TIERS_NAME = "device-tiers"; - public static final String DEVICE_TIER_ELEMENT_NAME = "device-tier"; + public static final String CONDITION_DEVICE_GROUPS_NAME = "device-groups"; + public static final String DEVICE_GROUP_ELEMENT_NAME = "device-group"; public static final String SPLIT_NAME_ATTRIBUTE_NAME = "splitName"; public static final String VERSION_NAME_ATTRIBUTE_NAME = "versionName"; public static final String INSTALL_LOCATION_ATTRIBUTE_NAME = "installLocation"; @@ -100,6 +100,7 @@ public abstract class AndroidManifest { public static final String MODULE_TYPE_FEATURE_VALUE = "feature"; public static final String MODULE_TYPE_ASSET_VALUE = "asset-pack"; + public static final String MODULE_TYPE_ML_VALUE = "ml-pack"; /** name that specifies native library for native activity */ public static final String NATIVE_ACTIVITY_LIB_NAME = "android.app.lib_name"; @@ -349,6 +350,8 @@ private static ModuleType getModuleTypeFromAttributeValue(String value) { return ModuleType.FEATURE_MODULE; case MODULE_TYPE_ASSET_VALUE: return ModuleType.ASSET_MODULE; + case MODULE_TYPE_ML_VALUE: + return ModuleType.ML_MODULE; default: throw InvalidBundleException.builder() .withUserMessage("Found invalid type attribute %s for element.", value) diff --git a/src/main/java/com/android/tools/build/bundletool/model/AppBundle.java b/src/main/java/com/android/tools/build/bundletool/model/AppBundle.java index 2e97a2fa..c96b1c70 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/AppBundle.java +++ b/src/main/java/com/android/tools/build/bundletool/model/AppBundle.java @@ -25,6 +25,7 @@ import com.android.bundle.Config.BundleConfig; import com.android.bundle.Config.BundleConfig.BundleType; +import com.android.bundle.Config.StandaloneConfig.DexMergingStrategy; import com.android.bundle.Files.TargetedNativeDirectory; import com.android.bundle.Targeting.Abi; import com.android.bundle.Targeting.NativeDirectoryTargeting; @@ -114,9 +115,15 @@ public static AppBundle buildFromModules( public abstract BundleMetadata getBundleMetadata(); + /** + * Returns all feature modules for this bundle, including the base module. + * + *

ML modules are treated as feature modules across bundletool and app delivery, so they are + * returned by this method. + */ public ImmutableMap getFeatureModules() { return getModules().values().stream() - .filter(module -> module.getModuleType().equals(ModuleType.FEATURE_MODULE)) + .filter(module -> module.getModuleType().isFeatureModule()) .collect(toImmutableMap(BundleModule::getName, identity())); } @@ -213,13 +220,24 @@ public boolean isAssetOnly() { return getBundleConfig().getType().equals(BundleType.ASSET_ONLY); } + /** Returns whether the base module has `sharedUserId` attribute in the manifest. */ + public boolean hasSharedUserId() { + return getBaseModule().getAndroidManifest().hasSharedUserId(); + } + /** - * Returns whether any of the feature modules specify `sharedUserId` attribute in the manifest. + * Returns {@code true} if bundletool will merge dex files when generating standalone APKs. This + * happens for applications with dynamic feature modules that have min sdk below 21 and specified + * DexMergingStrategy is MERGE_IF_NEEDED. */ - public boolean hasSharedUserId() { - return getFeatureModules().values().stream() - .map(BundleModule::getAndroidManifest) - .anyMatch(AndroidManifest::hasSharedUserId); + public boolean dexMergingEnabled() { + return getDexMergingStrategy().equals(DexMergingStrategy.MERGE_IF_NEEDED) + && getBaseModule().getAndroidManifest().getEffectiveMinSdkVersion() < 21 + && getFeatureModules().size() > 1; + } + + private DexMergingStrategy getDexMergingStrategy() { + return getBundleConfig().getOptimizations().getStandaloneConfig().getDexMergingStrategy(); } public abstract Builder toBuilder(); @@ -257,11 +275,31 @@ private static ImmutableList extractModules( .setContent(ZipUtils.asByteSource(bundleFile, entry)) .build()); } + + // We verify the presence of the manifest before building the BundleModule objects because the + // manifest is a required field of the BundleModule class. + checkModulesHaveManifest(moduleBuilders.values()); + return moduleBuilders.values().stream() .map(BundleModule.Builder::build) .collect(toImmutableList()); } + private static void checkModulesHaveManifest(Collection bundleModules) { + ImmutableSet modulesWithoutManifest = + bundleModules.stream() + .filter(bundleModule -> !bundleModule.hasAndroidManifest()) + .map(module -> module.getName().getName()) + .collect(toImmutableSet()); + if (!modulesWithoutManifest.isEmpty()) { + throw InvalidBundleException.builder() + .withUserMessage( + "Found modules in the App Bundle without an AndroidManifest.xml: %s", + modulesWithoutManifest) + .build(); + } + } + private static BundleConfig readBundleConfig(ZipFile bundleFile) { ZipEntry bundleConfigEntry = bundleFile.getEntry(BUNDLE_CONFIG_FILE_NAME); if (bundleConfigEntry == null) { diff --git a/src/main/java/com/android/tools/build/bundletool/model/BundleModule.java b/src/main/java/com/android/tools/build/bundletool/model/BundleModule.java index 5cb5d05c..731ff13a 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/BundleModule.java +++ b/src/main/java/com/android/tools/build/bundletool/model/BundleModule.java @@ -16,9 +16,6 @@ package com.android.tools.build.bundletool.model; -import static com.android.tools.build.bundletool.model.ModuleDeliveryType.ALWAYS_INITIAL_INSTALL; -import static com.android.tools.build.bundletool.model.ModuleDeliveryType.CONDITIONAL_INITIAL_INSTALL; -import static com.android.tools.build.bundletool.model.ModuleDeliveryType.NO_INITIAL_INSTALL; import static com.android.tools.build.bundletool.model.utils.TargetingProtoUtils.sdkVersionFrom; import static com.android.tools.build.bundletool.model.utils.TargetingProtoUtils.sdkVersionTargeting; import static com.google.common.base.Preconditions.checkArgument; @@ -28,6 +25,7 @@ import com.android.aapt.Resources.ResourceTable; import com.android.aapt.Resources.XmlNode; import com.android.bundle.Commands.DeliveryType; +import com.android.bundle.Commands.FeatureModuleType; import com.android.bundle.Commands.ModuleMetadata; import com.android.bundle.Config.BundleConfig; import com.android.bundle.Files.ApexImages; @@ -96,8 +94,20 @@ public abstract class BundleModule { /** Describes the content type of the module. */ public enum ModuleType { - FEATURE_MODULE, - ASSET_MODULE + FEATURE_MODULE(true), + ASSET_MODULE(false), + ML_MODULE(true); + + private final boolean isFeatureModule; + + ModuleType(boolean isFeatureModule) { + this.isFeatureModule = isFeatureModule; + } + + /** Returns whether the module is a feature, including ML modules. */ + public boolean isFeatureModule() { + return isFeatureModule; + } } /** The version of Bundletool that built this module, taken from BundleConfig. */ @@ -236,13 +246,18 @@ public Optional getEntry(ZipPath path) { } public ModuleMetadata getModuleMetadata() { - return ModuleMetadata.newBuilder() - .setName(getName().getName()) - .setIsInstant(isInstantModule()) - .addAllDependencies(getDependencies()) - .setTargeting(getModuleTargeting()) - .setDeliveryType(moduleDeliveryTypeToDeliveryType(getDeliveryType())) - .build(); + ModuleMetadata.Builder moduleMetadata = + ModuleMetadata.newBuilder() + .setName(getName().getName()) + .setIsInstant(isInstantModule()) + .addAllDependencies(getDependencies()) + .setTargeting(getModuleTargeting()) + .setDeliveryType(moduleDeliveryTypeToDeliveryType(getDeliveryType())); + + moduleTypeToFeatureModuleType(getModuleType()) + .ifPresent(moduleType -> moduleMetadata.setModuleType(moduleType)); + + return moduleMetadata.build(); } private static DeliveryType moduleDeliveryTypeToDeliveryType( @@ -257,6 +272,18 @@ private static DeliveryType moduleDeliveryTypeToDeliveryType( throw new IllegalArgumentException("Unknown module delivery type: " + moduleDeliveryType); } + private static Optional moduleTypeToFeatureModuleType(ModuleType moduleType) { + switch (moduleType) { + case FEATURE_MODULE: + return Optional.of(FeatureModuleType.FEATURE_MODULE); + case ML_MODULE: + return Optional.of(FeatureModuleType.ML_MODULE); + case ASSET_MODULE: + return Optional.empty(); + } + throw new IllegalArgumentException("Unknown module type: " + moduleType); + } + public static Builder builder() { return new AutoValue_BundleModule.Builder(); } @@ -336,6 +363,14 @@ public Builder addEntry(ModuleEntry moduleEntry) { return this; } + abstract Optional getAndroidManifestProto(); + + public boolean hasAndroidManifest() { + return getAndroidManifestProto().isPresent(); + } + + public abstract BundleModuleName getName(); + public abstract BundleModule build(); } diff --git a/src/main/java/com/android/tools/build/bundletool/model/DeviceTiersCondition.java b/src/main/java/com/android/tools/build/bundletool/model/DeviceGroupsCondition.java similarity index 77% rename from src/main/java/com/android/tools/build/bundletool/model/DeviceTiersCondition.java rename to src/main/java/com/android/tools/build/bundletool/model/DeviceGroupsCondition.java index 8c550ac1..e9922be7 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/DeviceTiersCondition.java +++ b/src/main/java/com/android/tools/build/bundletool/model/DeviceGroupsCondition.java @@ -19,13 +19,13 @@ import com.google.common.collect.ImmutableSet; import com.google.errorprone.annotations.Immutable; -/** A {@link BundleModule} condition describing a set of device tiers. */ +/** A {@link BundleModule} condition describing a set of device groups. */ @Immutable @AutoValue -public abstract class DeviceTiersCondition { - public abstract ImmutableSet getDeviceTiers(); +public abstract class DeviceGroupsCondition { + public abstract ImmutableSet getDeviceGroups(); - public static DeviceTiersCondition create(ImmutableSet deviceTiers) { - return new AutoValue_DeviceTiersCondition(deviceTiers); + public static DeviceGroupsCondition create(ImmutableSet deviceGroups) { + return new AutoValue_DeviceGroupsCondition(deviceGroups); } } diff --git a/src/main/java/com/android/tools/build/bundletool/model/ManifestDeliveryElement.java b/src/main/java/com/android/tools/build/bundletool/model/ManifestDeliveryElement.java index 33cc2d0f..aaec5e0c 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/ManifestDeliveryElement.java +++ b/src/main/java/com/android/tools/build/bundletool/model/ManifestDeliveryElement.java @@ -18,12 +18,12 @@ import static com.android.tools.build.bundletool.model.AndroidManifest.CODE_ATTRIBUTE_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_DEVICE_FEATURE_NAME; -import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_DEVICE_TIERS_NAME; +import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_DEVICE_GROUPS_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_MAX_SDK_VERSION_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_MIN_SDK_VERSION_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_USER_COUNTRIES_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.COUNTRY_ELEMENT_NAME; -import static com.android.tools.build.bundletool.model.AndroidManifest.DEVICE_TIER_ELEMENT_NAME; +import static com.android.tools.build.bundletool.model.AndroidManifest.DEVICE_GROUP_ELEMENT_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.DISTRIBUTION_NAMESPACE_URI; import static com.android.tools.build.bundletool.model.AndroidManifest.EXCLUDE_ATTRIBUTE_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.NAME_ATTRIBUTE_NAME; @@ -35,7 +35,7 @@ import com.android.aapt.Resources.XmlNode; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; -import com.android.tools.build.bundletool.model.utils.DeviceTierUtils; +import com.android.tools.build.bundletool.model.utils.DeviceTargetingUtils; import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoAttribute; import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoElement; import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoNode; @@ -68,7 +68,7 @@ public abstract class ManifestDeliveryElement { CONDITION_MIN_SDK_VERSION_NAME, CONDITION_MAX_SDK_VERSION_NAME, CONDITION_USER_COUNTRIES_NAME, - CONDITION_DEVICE_TIERS_NAME); + CONDITION_DEVICE_GROUPS_NAME); abstract XmlProtoElement getDeliveryElement(); @@ -190,8 +190,8 @@ public ModuleConditions getModuleConditions() { case CONDITION_USER_COUNTRIES_NAME: moduleConditions.setUserCountriesCondition(parseUserCountriesCondition(conditionElement)); break; - case CONDITION_DEVICE_TIERS_NAME: - moduleConditions.setDeviceTiersCondition(parseDeviceTiersCondition(conditionElement)); + case CONDITION_DEVICE_GROUPS_NAME: + moduleConditions.setDeviceGroupsCondition(parseDeviceGroupsCondition(conditionElement)); break; default: throw InvalidBundleException.builder() @@ -251,31 +251,31 @@ private UserCountriesCondition parseUserCountriesCondition(XmlProtoElement condi return UserCountriesCondition.create(countryCodes.build(), exclude); } - private DeviceTiersCondition parseDeviceTiersCondition(XmlProtoElement conditionElement) { + private DeviceGroupsCondition parseDeviceGroupsCondition(XmlProtoElement conditionElement) { ImmutableList children = conditionElement.getChildrenElements().collect(toImmutableList()); if (children.isEmpty()) { throw InvalidBundleException.builder() .withUserMessage( - "At least one device tier should be specified in '' element.", - CONDITION_DEVICE_TIERS_NAME) + "At least one device group should be specified in '' element.", + CONDITION_DEVICE_GROUPS_NAME) .build(); } - ImmutableSet.Builder deviceTiers = ImmutableSet.builder(); - for (XmlProtoElement deviceTierElement : children) { - if (!deviceTierElement.getName().equals(DEVICE_TIER_ELEMENT_NAME)) { + ImmutableSet.Builder deviceGroups = ImmutableSet.builder(); + for (XmlProtoElement deviceGroupElement : children) { + if (!deviceGroupElement.getName().equals(DEVICE_GROUP_ELEMENT_NAME)) { throw InvalidBundleException.builder() .withUserMessage( "Expected only '' elements inside '', but found %s.", - DEVICE_TIER_ELEMENT_NAME, - CONDITION_DEVICE_TIERS_NAME, - printElement(deviceTierElement)) + DEVICE_GROUP_ELEMENT_NAME, + CONDITION_DEVICE_GROUPS_NAME, + printElement(deviceGroupElement)) .build(); } - String tierName = - deviceTierElement + String groupName = + deviceGroupElement .getAttribute(DISTRIBUTION_NAMESPACE_URI, NAME_ATTRIBUTE_NAME) .map(XmlProtoAttribute::getValueAsString) .orElseThrow( @@ -284,12 +284,12 @@ private DeviceTiersCondition parseDeviceTiersCondition(XmlProtoElement condition .withUserMessage( "'' element is expected to have 'dist:%s' attribute " + "but found none.", - DEVICE_TIER_ELEMENT_NAME, NAME_ATTRIBUTE_NAME) + DEVICE_GROUP_ELEMENT_NAME, NAME_ATTRIBUTE_NAME) .build()); - DeviceTierUtils.validateDeviceTierForConditionalModule(tierName); - deviceTiers.add(tierName); + DeviceTargetingUtils.validateDeviceGroupForConditionalModule(groupName); + deviceGroups.add(groupName); } - return DeviceTiersCondition.create(deviceTiers.build()); + return DeviceGroupsCondition.create(deviceGroups.build()); } private static void validateDeliveryElement( diff --git a/src/main/java/com/android/tools/build/bundletool/model/ModuleConditions.java b/src/main/java/com/android/tools/build/bundletool/model/ModuleConditions.java index ca32317f..00f74e0b 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/ModuleConditions.java +++ b/src/main/java/com/android/tools/build/bundletool/model/ModuleConditions.java @@ -18,7 +18,7 @@ import com.android.bundle.Targeting.DeviceFeature; import com.android.bundle.Targeting.DeviceFeatureTargeting; -import com.android.bundle.Targeting.DeviceTierModuleTargeting; +import com.android.bundle.Targeting.DeviceGroupModuleTargeting; import com.android.bundle.Targeting.ModuleTargeting; import com.android.bundle.Targeting.UserCountriesTargeting; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; @@ -44,7 +44,7 @@ public abstract class ModuleConditions { public abstract Optional getUserCountriesCondition(); - public abstract Optional getDeviceTiersCondition(); + public abstract Optional getDeviceGroupsCondition(); public boolean isEmpty() { return toTargeting().equals(ModuleTargeting.getDefaultInstance()); @@ -94,10 +94,10 @@ public ModuleTargeting toTargeting() { .build()); } - if (getDeviceTiersCondition().isPresent()) { - moduleTargeting.setDeviceTierTargeting( - DeviceTierModuleTargeting.newBuilder() - .addAllValue(getDeviceTiersCondition().get().getDeviceTiers()) + if (getDeviceGroupsCondition().isPresent()) { + moduleTargeting.setDeviceGroupTargeting( + DeviceGroupModuleTargeting.newBuilder() + .addAllValue(getDeviceGroupsCondition().get().getDeviceGroups()) .build()); } @@ -122,7 +122,7 @@ public Builder addDeviceFeatureCondition(DeviceFeatureCondition deviceFeatureCon public abstract Builder setUserCountriesCondition( UserCountriesCondition userCountriesCondition); - public abstract Builder setDeviceTiersCondition(DeviceTiersCondition deviceTiersCondition); + public abstract Builder setDeviceGroupsCondition(DeviceGroupsCondition deviceGroupsCondition); protected abstract ModuleConditions autoBuild(); diff --git a/src/main/java/com/android/tools/build/bundletool/model/ModuleSplit.java b/src/main/java/com/android/tools/build/bundletool/model/ModuleSplit.java index df356f85..7f816152 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/ModuleSplit.java +++ b/src/main/java/com/android/tools/build/bundletool/model/ModuleSplit.java @@ -511,6 +511,7 @@ private static ModuleSplit fromBundleModule( private static SplitType getSplitTypeFromModuleType(ModuleType moduleType) { switch (moduleType) { case FEATURE_MODULE: + case ML_MODULE: return SplitType.SPLIT; case ASSET_MODULE: return SplitType.ASSET_SLICE; diff --git a/src/main/java/com/android/tools/build/bundletool/model/ZipPath.java b/src/main/java/com/android/tools/build/bundletool/model/ZipPath.java index b45ed771..4308cf87 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/ZipPath.java +++ b/src/main/java/com/android/tools/build/bundletool/model/ZipPath.java @@ -177,8 +177,9 @@ public final int compareTo(ZipPath other) { } /** Returns the path as used in the zip file. */ + @Memoized @Override - public final String toString() { + public String toString() { return JOINER.join(getNames()); } diff --git a/src/main/java/com/android/tools/build/bundletool/model/targeting/TargetedDirectorySegment.java b/src/main/java/com/android/tools/build/bundletool/model/targeting/TargetedDirectorySegment.java index ab7f8303..ef7d1dbf 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/targeting/TargetedDirectorySegment.java +++ b/src/main/java/com/android/tools/build/bundletool/model/targeting/TargetedDirectorySegment.java @@ -23,7 +23,7 @@ import com.android.bundle.Targeting.DeviceTierTargeting; import com.android.bundle.Targeting.LanguageTargeting; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; -import com.android.tools.build.bundletool.model.utils.DeviceTierUtils; +import com.android.tools.build.bundletool.model.utils.DeviceTargetingUtils; import com.android.tools.build.bundletool.model.utils.TextureCompressionUtils; import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableList; @@ -244,7 +244,7 @@ private static AssetsDirectoryTargeting parseLanguage(String name, String value) } private static AssetsDirectoryTargeting parseDeviceTier(String name, String value) { - DeviceTierUtils.validateDeviceTierForAssetsDirectory(name, value); + DeviceTargetingUtils.validateDeviceTierForAssetsDirectory(name, value); return AssetsDirectoryTargeting.newBuilder() .setDeviceTier(DeviceTierTargeting.newBuilder().addValue(value)) .build(); diff --git a/src/main/java/com/android/tools/build/bundletool/model/utils/DeviceTierUtils.java b/src/main/java/com/android/tools/build/bundletool/model/utils/DeviceTargetingUtils.java similarity index 70% rename from src/main/java/com/android/tools/build/bundletool/model/utils/DeviceTierUtils.java rename to src/main/java/com/android/tools/build/bundletool/model/utils/DeviceTargetingUtils.java index 7ad80d10..12c6594c 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/utils/DeviceTierUtils.java +++ b/src/main/java/com/android/tools/build/bundletool/model/utils/DeviceTargetingUtils.java @@ -15,13 +15,16 @@ */ package com.android.tools.build.bundletool.model.utils; -import static com.android.tools.build.bundletool.model.AndroidManifest.DEVICE_TIER_ELEMENT_NAME; +import static com.android.tools.build.bundletool.model.AndroidManifest.DEVICE_GROUP_ELEMENT_NAME; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; import java.util.regex.Pattern; -/** Utilities for device tier names. */ -public class DeviceTierUtils { +/** Utilities for device group and tier values. */ +public class DeviceTargetingUtils { + private static final Pattern DEVICE_GROUP_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9_]*"); + + @Deprecated private static final Pattern DEVICE_TIER_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9_]*"); public static void validateDeviceTierForAssetsDirectory(String directory, String tierName) { @@ -35,17 +38,17 @@ public static void validateDeviceTierForAssetsDirectory(String directory, String } } - public static void validateDeviceTierForConditionalModule(String tierName) { - if (!DEVICE_TIER_PATTERN.matcher(tierName).matches()) { + public static void validateDeviceGroupForConditionalModule(String groupName) { + if (!DEVICE_GROUP_PATTERN.matcher(groupName).matches()) { throw InvalidBundleException.builder() .withUserMessage( - "Device tier names should start with a letter and contain only letters, numbers and" - + " underscores. Found tier named '%s' in '' element.", - tierName, DEVICE_TIER_ELEMENT_NAME) + "Device group names should start with a letter and contain only letters, numbers and" + + " underscores. Found group named '%s' in '' element.", + groupName, DEVICE_GROUP_ELEMENT_NAME) .build(); } } // Do not instantiate. - private DeviceTierUtils() {} + private DeviceTargetingUtils() {} } diff --git a/src/main/java/com/android/tools/build/bundletool/model/version/BundleToolVersion.java b/src/main/java/com/android/tools/build/bundletool/model/version/BundleToolVersion.java index 76934ead..f6f098ac 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/version/BundleToolVersion.java +++ b/src/main/java/com/android/tools/build/bundletool/model/version/BundleToolVersion.java @@ -26,7 +26,7 @@ */ public final class BundleToolVersion { - private static final String CURRENT_VERSION = "1.6.1"; + private static final String CURRENT_VERSION = "1.7.0"; /** Returns the version of BundleTool being run. */ public static Version getCurrentVersion() { diff --git a/src/main/java/com/android/tools/build/bundletool/model/version/VersionGuardedFeature.java b/src/main/java/com/android/tools/build/bundletool/model/version/VersionGuardedFeature.java index 259bff49..fbd03326 100644 --- a/src/main/java/com/android/tools/build/bundletool/model/version/VersionGuardedFeature.java +++ b/src/main/java/com/android/tools/build/bundletool/model/version/VersionGuardedFeature.java @@ -15,6 +15,8 @@ */ package com.android.tools.build.bundletool.model.version; +import java.util.Optional; + /** Features that are enabled only from a certain Bundletool version. */ public enum VersionGuardedFeature { @@ -40,9 +42,9 @@ public enum VersionGuardedFeature { /** * Move the resources referenced in the AndroidManifest.xml into the master split to ensure * Application.create() will be invoked even if other resources are missing. Added for the - * Sideloading API. + * Sideloading API, which was deprecated in 1.7.0. Disabling this reduces app sizes. */ - RESOURCES_REFERENCED_IN_MANIFEST_TO_MASTER_SPLIT("0.8.1"), + RESOURCES_REFERENCED_IN_MANIFEST_TO_MASTER_SPLIT("0.8.1", "1.7.0"), /** * Resources under "drawable-mdpi" take precedence over ones under "drawable". Although they @@ -87,8 +89,22 @@ public enum VersionGuardedFeature { /** Version from which the given feature should be enabled by default. */ private final Version enabledSinceVersion; + /** + * Version from which the given feature should be disabled by default. + * + *

This provides an exclusive upper bound for {@link enabledSinceVersion}. If missing, feature + * is enabled indefinitely. + */ + private final Optional disabledSinceVersion; + VersionGuardedFeature(String enabledSinceVersion) { this.enabledSinceVersion = Version.of(enabledSinceVersion); + this.disabledSinceVersion = Optional.empty(); + } + + VersionGuardedFeature(String enabledSinceVersion, String disabledSinceVersion) { + this.enabledSinceVersion = Version.of(enabledSinceVersion); + this.disabledSinceVersion = Optional.of(Version.of(disabledSinceVersion)); } /** @@ -97,6 +113,10 @@ public enum VersionGuardedFeature { * @param bundletoolVersion The version of bundletool that was used to build the App Bundle. */ public boolean enabledForVersion(Version bundletoolVersion) { - return !bundletoolVersion.isOlderThan(enabledSinceVersion); + if (bundletoolVersion.isOlderThan(enabledSinceVersion)) { + return false; + } + + return disabledSinceVersion.map(bundletoolVersion::isOlderThan).orElse(true); } } diff --git a/src/main/java/com/android/tools/build/bundletool/optimizations/OptimizationsMerger.java b/src/main/java/com/android/tools/build/bundletool/optimizations/OptimizationsMerger.java index 9af6125d..d2f8bdb1 100644 --- a/src/main/java/com/android/tools/build/bundletool/optimizations/OptimizationsMerger.java +++ b/src/main/java/com/android/tools/build/bundletool/optimizations/OptimizationsMerger.java @@ -103,7 +103,10 @@ public ApkOptimizations mergeWithDefaults( ? requestedOptimizations.getUncompressNativeLibraries().getEnabled() : defaultOptimizations.getUncompressNativeLibraries(); - boolean uncompressDexFiles = requestedOptimizations.getUncompressDexFiles().getEnabled(); + boolean uncompressDexFiles = + requestedOptimizations.hasUncompressDexFiles() + ? requestedOptimizations.getUncompressDexFiles().getEnabled() + : defaultOptimizations.getUncompressDexFiles(); ImmutableMap suffixStrippings = getSuffixStrippings( diff --git a/src/main/java/com/android/tools/build/bundletool/shards/StandaloneApksGenerator.java b/src/main/java/com/android/tools/build/bundletool/shards/StandaloneApksGenerator.java index 7a18ce70..77620153 100644 --- a/src/main/java/com/android/tools/build/bundletool/shards/StandaloneApksGenerator.java +++ b/src/main/java/com/android/tools/build/bundletool/shards/StandaloneApksGenerator.java @@ -20,7 +20,7 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import com.android.tools.build.bundletool.mergers.ModuleSplitsToShardMerger; -import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.ModuleEntry; import com.android.tools.build.bundletool.model.ModuleSplit; @@ -52,12 +52,12 @@ public StandaloneApksGenerator( ModuleSplitterForShards moduleSplitter, Sharder sharder, ModuleSplitsToShardMerger shardsMerger, - BundleMetadata bundleMetadata) { + AppBundle appBundle) { this.stampSource = stampSource; this.moduleSplitter = moduleSplitter; this.sharder = sharder; this.shardsMerger = shardsMerger; - this.codeTransparencyInjector = new CodeTransparencyInjector(bundleMetadata); + this.codeTransparencyInjector = new CodeTransparencyInjector(appBundle); } /** diff --git a/src/main/java/com/android/tools/build/bundletool/shards/SystemApksGenerator.java b/src/main/java/com/android/tools/build/bundletool/shards/SystemApksGenerator.java index 64a6d6db..44f93f7a 100644 --- a/src/main/java/com/android/tools/build/bundletool/shards/SystemApksGenerator.java +++ b/src/main/java/com/android/tools/build/bundletool/shards/SystemApksGenerator.java @@ -28,7 +28,7 @@ import com.android.tools.build.bundletool.device.ApkMatcher; import com.android.tools.build.bundletool.mergers.ModuleSplitsToShardMerger; import com.android.tools.build.bundletool.model.AndroidManifest; -import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.BundleModuleName; import com.android.tools.build.bundletool.model.ModuleEntry; @@ -63,12 +63,12 @@ public SystemApksGenerator( Sharder sharder, ModuleSplitsToShardMerger shardsMerger, Optional deviceSpec, - BundleMetadata bundleMetadata) { + AppBundle appBundle) { this.moduleSplitter = moduleSplitter; this.sharder = sharder; this.shardsMerger = shardsMerger; this.deviceSpec = deviceSpec; - this.codeTransparencyInjector = new CodeTransparencyInjector(bundleMetadata); + this.codeTransparencyInjector = new CodeTransparencyInjector(appBundle); } /** diff --git a/src/main/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjector.java b/src/main/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjector.java index 32338b01..900c0d99 100644 --- a/src/main/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjector.java +++ b/src/main/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjector.java @@ -15,7 +15,7 @@ */ package com.android.tools.build.bundletool.splitters; -import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.ModuleSplit; import com.android.tools.build.bundletool.model.ModuleSplit.SplitType; @@ -26,22 +26,27 @@ */ public final class CodeTransparencyInjector { - private final BundleMetadata bundleMetadata; + private final AppBundle appBundle; - public CodeTransparencyInjector(BundleMetadata bundleMetadata) { - this.bundleMetadata = bundleMetadata; + public CodeTransparencyInjector(AppBundle appBundle) { + this.appBundle = appBundle; } public ModuleSplit inject(ModuleSplit split) { ModuleSplit.Builder splitBuilder = split.toBuilder(); if (shouldPropagateTransparency(split)) { - bundleMetadata.getModuleEntryForSignedTransparencyFile().ifPresent(splitBuilder::addEntry); + appBundle + .getBundleMetadata() + .getModuleEntryForSignedTransparencyFile() + .ifPresent(splitBuilder::addEntry); } return splitBuilder.build(); } - private static boolean shouldPropagateTransparency(ModuleSplit split) { - return split.getSplitType() == SplitType.STANDALONE - || (split.isMasterSplit() && split.isBaseModuleSplit()); + private boolean shouldPropagateTransparency(ModuleSplit split) { + if (split.getSplitType() == SplitType.STANDALONE) { + return !appBundle.dexMergingEnabled(); + } + return split.isMasterSplit() && split.isBaseModuleSplit(); } } diff --git a/src/main/java/com/android/tools/build/bundletool/splitters/ModuleSplitter.java b/src/main/java/com/android/tools/build/bundletool/splitters/ModuleSplitter.java index f6fe6694..24d21741 100644 --- a/src/main/java/com/android/tools/build/bundletool/splitters/ModuleSplitter.java +++ b/src/main/java/com/android/tools/build/bundletool/splitters/ModuleSplitter.java @@ -26,12 +26,14 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import com.android.aapt.ConfigurationOuterClass.Configuration; +import com.android.bundle.Config.BundleConfig; import com.android.bundle.Targeting.ApkTargeting; import com.android.bundle.Targeting.SdkVersion; import com.android.bundle.Targeting.SdkVersionTargeting; import com.android.bundle.Targeting.VariantTargeting; import com.android.tools.build.bundletool.mergers.SameTargetingMerger; import com.android.tools.build.bundletool.model.AndroidManifest; +import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleMetadata; import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.ManifestEditor; @@ -82,7 +84,10 @@ public static ModuleSplitter createForTest(BundleModule module, Version bundleVe return new ModuleSplitter( module, bundleVersion, - BundleMetadata.builder().build(), + AppBundle.buildFromModules( + ImmutableList.of(module), + BundleConfig.getDefaultInstance(), + BundleMetadata.builder().build()), ApkGenerationConfiguration.getDefaultInstance(), lPlusVariantTargeting(), /* allModuleNames= */ ImmutableSet.of(), @@ -93,14 +98,14 @@ public static ModuleSplitter createForTest(BundleModule module, Version bundleVe public static ModuleSplitter createNoStamp( BundleModule module, Version bundleVersion, - BundleMetadata bundleMetadata, + AppBundle appBundle, ApkGenerationConfiguration apkGenerationConfiguration, VariantTargeting variantTargeting, ImmutableSet allModuleNames) { return new ModuleSplitter( module, bundleVersion, - bundleMetadata, + appBundle, apkGenerationConfiguration, variantTargeting, allModuleNames, @@ -111,7 +116,7 @@ public static ModuleSplitter createNoStamp( public static ModuleSplitter create( BundleModule module, Version bundleVersion, - BundleMetadata bundleMetadata, + AppBundle appBundle, ApkGenerationConfiguration apkGenerationConfiguration, VariantTargeting variantTargeting, ImmutableSet allModuleNames, @@ -120,7 +125,7 @@ public static ModuleSplitter create( return new ModuleSplitter( module, bundleVersion, - bundleMetadata, + appBundle, apkGenerationConfiguration, variantTargeting, allModuleNames, @@ -131,7 +136,7 @@ public static ModuleSplitter create( private ModuleSplitter( BundleModule module, Version bundleVersion, - BundleMetadata bundleMetadata, + AppBundle appBundle, ApkGenerationConfiguration apkGenerationConfiguration, VariantTargeting variantTargeting, ImmutableSet allModuleNames, @@ -144,7 +149,7 @@ private ModuleSplitter( this.abiPlaceholderInjector = new AbiPlaceholderInjector(apkGenerationConfiguration.getAbisForPlaceholderLibs()); this.pinSpecInjector = new PinSpecInjector(module); - this.codeTransparencyInjector = new CodeTransparencyInjector(bundleMetadata); + this.codeTransparencyInjector = new CodeTransparencyInjector(appBundle); this.allModuleNames = allModuleNames; this.stampSource = stampSource; this.stampType = stampType; diff --git a/src/main/java/com/android/tools/build/bundletool/splitters/SplitApksGenerator.java b/src/main/java/com/android/tools/build/bundletool/splitters/SplitApksGenerator.java index 61362874..84701618 100644 --- a/src/main/java/com/android/tools/build/bundletool/splitters/SplitApksGenerator.java +++ b/src/main/java/com/android/tools/build/bundletool/splitters/SplitApksGenerator.java @@ -21,7 +21,7 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet; import com.android.bundle.Targeting.VariantTargeting; -import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.ModuleSplit; import com.android.tools.build.bundletool.model.SourceStamp; @@ -38,18 +38,18 @@ public final class SplitApksGenerator { private final Version bundletoolVersion; private final Optional stampSource; private final VariantGenerator variantGenerator; - private final BundleMetadata bundleMetadata; + private final AppBundle appBundle; @Inject public SplitApksGenerator( Version bundletoolVersion, Optional stampSource, VariantGenerator variantGenerator, - BundleMetadata bundleMetadata) { + AppBundle appBundle) { this.bundletoolVersion = bundletoolVersion; this.stampSource = stampSource; this.variantGenerator = variantGenerator; - this.bundleMetadata = bundleMetadata; + this.appBundle = appBundle; } public ImmutableList generateSplits( @@ -87,7 +87,7 @@ private ImmutableList generateSplitApks( ModuleSplitter.create( module, bundletoolVersion, - bundleMetadata, + appBundle, apkGenerationConfiguration, variantTargeting, allModuleNames, diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/ApkModeTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/transparency/ApkModeTransparencyChecker.java new file mode 100644 index 00000000..70ea3896 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/ApkModeTransparencyChecker.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.android.tools.build.bundletool.commands.CheckTransparencyCommand; +import com.android.tools.build.bundletool.io.TempDirectory; +import com.android.tools.build.bundletool.model.ZipPath; +import com.android.tools.build.bundletool.model.utils.ZipUtils; +import com.google.common.collect.ImmutableList; +import com.google.common.io.ByteStreams; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** Executes {@link CheckTransparencyCommand} in APK mode. */ +public final class ApkModeTransparencyChecker { + + public static TransparencyCheckResult checkTransparency(CheckTransparencyCommand command) { + try (TempDirectory tempDir = new TempDirectory("apk-transparency-checker")) { + return ApkTransparencyCheckUtils.checkTransparency( + extractAllApksFromZip(command.getApkZipPath().get(), tempDir)); + } catch (IOException e) { + throw new UncheckedIOException("An error occurred when processing the file.", e); + } + } + + /** Returns list of paths to all .apk files extracted from a .zip file. */ + private static ImmutableList extractAllApksFromZip( + Path zipOfApksPath, TempDirectory tempDirectory) throws IOException { + ImmutableList.Builder allExtractedApkPaths = ImmutableList.builder(); + Path zipExtractedSubDirectory = tempDirectory.getPath().resolve("extracted"); + Files.createDirectory(zipExtractedSubDirectory); + + try (ZipFile zipOfApks = ZipUtils.openZipFile(zipOfApksPath)) { + ImmutableList listOfApksToExtract = + zipOfApks.stream() + .filter( + zipEntry -> + !zipEntry.isDirectory() + && zipEntry.getName().toLowerCase(Locale.ROOT).endsWith(".apk")) + .collect(toImmutableList()); + + for (ZipEntry apkToExtract : listOfApksToExtract) { + Path extractedApkPath = + zipExtractedSubDirectory.resolve(ZipPath.create(apkToExtract.getName()).toString()); + Files.createDirectories(extractedApkPath.getParent()); + try (InputStream inputStream = zipOfApks.getInputStream(apkToExtract); + OutputStream outputApk = Files.newOutputStream(extractedApkPath)) { + ByteStreams.copy(inputStream, outputApk); + allExtractedApkPaths.add(extractedApkPath); + } + } + } + return allExtractedApkPaths.build(); + } + + private ApkModeTransparencyChecker() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/ApkSignatureVerifier.java b/src/main/java/com/android/tools/build/bundletool/transparency/ApkSignatureVerifier.java new file mode 100644 index 00000000..a535574e --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/ApkSignatureVerifier.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.android.apksig.ApkVerifier; +import com.android.apksig.apk.ApkFormatException; +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.nio.file.Path; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Optional; + +/** Verifies APK signature for the set of device-specific APKs. */ +final class ApkSignatureVerifier { + + /** Verifies signature of each APK and returns the public certificate of the app signing key. */ + static Result verify(ImmutableList deviceSpecificApks) { + checkArgument( + !deviceSpecificApks.isEmpty(), "Expected non-empty list of device-specific APKs."); + + Optional apkSigningKeyCertificate = Optional.empty(); + try { + for (Path apkPath : deviceSpecificApks) { + ApkVerifier.Result apkSignatureVerificationResult = + new ApkVerifier.Builder(apkPath.toFile()).build().verify(); + if (!apkSignatureVerificationResult.isVerified()) { + return Result.failure("APK signature invalid for " + apkPath.getFileName()); + } + X509Certificate currentCertificate = + apkSignatureVerificationResult.getSignerCertificates().get(0); + if (apkSigningKeyCertificate.isPresent()) { + if (!apkSigningKeyCertificate.get().equals(currentCertificate)) { + return Result.failure( + "APK signature verification failed: the keys used to sign the given set of device" + + " specific APKs do not match."); + } + } else { + apkSigningKeyCertificate = Optional.of(currentCertificate); + } + } + } catch (IOException | ApkFormatException | NoSuchAlgorithmException e) { + throw CommandExecutionException.builder() + .withInternalMessage("Exception during APK signature verification.") + .withCause(e) + .build(); + } + return Result.success( + CodeTransparencyCryptoUtils.getCertificateFingerprint(apkSigningKeyCertificate.get())); + } + + /** Represents result of {@link ApkSignatureVerifier#verify}. */ + @AutoValue + abstract static class Result { + abstract Optional apkSigningKeyCertificateFingerprint(); + + abstract Optional errorMessage(); + + /** Returns true if the APK signatures were successfully verified, and false otherwise. */ + boolean verified() { + return apkSigningKeyCertificateFingerprint().isPresent() && !errorMessage().isPresent(); + } + + /** + * If {@link #verified()} is true, returns APK signing key certificate fingerprint. Returns an + * empty string otherwise. + */ + String getApkSigningKeyCertificateFingerprint() { + return apkSigningKeyCertificateFingerprint().orElse(""); + } + + /** + * If {@link #verified()} is false, returns error message explaining why verification failed. + * Returns an empty string otherwise. + */ + String getErrorMessage() { + return errorMessage().orElse(""); + } + + static Result success(String apkSigningKeyCertificateFingerprint) { + return new AutoValue_ApkSignatureVerifier_Result( + Optional.of(apkSigningKeyCertificateFingerprint), /* errorMessage= */ Optional.empty()); + } + + static Result failure(String errorMessage) { + return new AutoValue_ApkSignatureVerifier_Result( + /* apkSigningKeyCertificateFingerprint= */ Optional.empty(), Optional.of(errorMessage)); + } + } + + private ApkSignatureVerifier() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/ApkTransparencyCheckUtils.java b/src/main/java/com/android/tools/build/bundletool/transparency/ApkTransparencyCheckUtils.java new file mode 100644 index 00000000..c9799007 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/ApkTransparencyCheckUtils.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; +import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; +import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; +import com.android.tools.build.bundletool.model.utils.ZipUtils; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.jose4j.jws.JsonWebSignature; + +/** Helper class for verifying code transparency for a given set of device-specific APKs. */ +public final class ApkTransparencyCheckUtils { + + private static final String TRANSPARENCY_FILE_ZIP_ENTRY_NAME = + "META-INF/" + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME; + + public static TransparencyCheckResult checkTransparency(ImmutableList deviceSpecificApks) { + Optional baseApkPath = getBaseApkPath(deviceSpecificApks); + if (!baseApkPath.isPresent()) { + throw InvalidCommandException.builder() + .withInternalMessage( + "The provided list of device specific APKs must either contain a single APK, or, if" + + " multiple APK files are present, base.apk file.") + .build(); + } + + TransparencyCheckResult.Builder result = TransparencyCheckResult.builder(); + ApkSignatureVerifier.Result apkSignatureVerificationResult = + ApkSignatureVerifier.verify(deviceSpecificApks); + if (!apkSignatureVerificationResult.verified()) { + return result + .errorMessage("Verification failed: " + apkSignatureVerificationResult.getErrorMessage()) + .build(); + } + result.apkSigningKeyCertificateFingerprint( + apkSignatureVerificationResult.getApkSigningKeyCertificateFingerprint()); + + try (ZipFile baseApkFile = ZipUtils.openZipFile(baseApkPath.get())) { + Optional transparencyFileEntry = + Optional.ofNullable(baseApkFile.getEntry(TRANSPARENCY_FILE_ZIP_ENTRY_NAME)); + if (!transparencyFileEntry.isPresent()) { + throw InvalidCommandException.builder() + .withInternalMessage( + "Could not verify code transparency because transparency file is not present in the" + + " APK.") + .build(); + } + + JsonWebSignature jws = + CodeTransparencyCryptoUtils.parseJws( + ZipUtils.asByteSource(baseApkFile, transparencyFileEntry.get())); + boolean signatureVerified = CodeTransparencyCryptoUtils.verifySignature(jws); + if (!signatureVerified) { + return result + .errorMessage("Verification failed because code transparency signature is invalid.") + .build(); + } + result + .transparencySignatureVerified(true) + .transparencyKeyCertificateFingerprint( + CodeTransparencyCryptoUtils.getCertificateFingerprint(jws)); + + CodeTransparency codeTransparencyMetadata = + CodeTransparencyFactory.parseFrom(jws.getUnverifiedPayload()); + ImmutableSet pathsToModifiedFiles = + getModifiedFiles(codeTransparencyMetadata, deviceSpecificApks); + result.fileContentsVerified(pathsToModifiedFiles.isEmpty()); + if (!pathsToModifiedFiles.isEmpty()) { + result.errorMessage( + "Verification failed because code was modified after code transparency metadata" + + " generation. Modified files: " + + pathsToModifiedFiles); + } + return result.build(); + } catch (IOException e) { + throw new UncheckedIOException("An error occurred when processing the file.", e); + } + } + + private static Optional getBaseApkPath(ImmutableList apkPaths) { + // If only 1 APK is present, it is assumed to be a universal or standalone APK. + if (apkPaths.size() == 1) { + return apkPaths.get(0).getFileName().toString().endsWith(".apk") + ? Optional.of(apkPaths.get(0)) + : Optional.empty(); + } + return apkPaths.stream() + .filter(apkPath -> apkPath.getFileName().toString().equals("base.apk")) + .findAny(); + } + + private static ImmutableSet getModifiedFiles( + CodeTransparency codeTransparencyMetadata, ImmutableList allApkPaths) { + ImmutableSet.Builder pathsToModifiedFilesBuilder = ImmutableSet.builder(); + for (Path apkPath : allApkPaths) { + try (ZipFile apkFile = ZipUtils.openZipFile(apkPath)) { + pathsToModifiedFilesBuilder.addAll(getModifiedDexFiles(apkFile, codeTransparencyMetadata)); + pathsToModifiedFilesBuilder.addAll( + getModifiedNativeLibraries(apkFile, codeTransparencyMetadata)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return pathsToModifiedFilesBuilder.build(); + } + + private static ImmutableSet getModifiedDexFiles( + ZipFile apkFile, CodeTransparency codeTransparencyMetadata) { + ImmutableSet expectedDexFiles = getDexFiles(codeTransparencyMetadata); + ImmutableSet.Builder pathsToModifiedFilesBuilder = ImmutableSet.builder(); + apkFile.stream() + .forEach( + zipEntry -> { + String fileHash = getFileHash(apkFile, zipEntry); + if (isDexFile(zipEntry) && !expectedDexFiles.contains(fileHash)) { + pathsToModifiedFilesBuilder.add(zipEntry.getName()); + } + }); + return pathsToModifiedFilesBuilder.build(); + } + + private static ImmutableSet getModifiedNativeLibraries( + ZipFile apkFile, CodeTransparency codeTransparencyMetadata) { + ImmutableMap expectedNativeLibrariesByApkPath = + getNativeLibrariesByApkPath(codeTransparencyMetadata); + ImmutableSet.Builder pathsToModifiedFilesBuilder = ImmutableSet.builder(); + apkFile.stream() + .forEach( + zipEntry -> { + String fileHash = getFileHash(apkFile, zipEntry); + if (isNativeLibrary(zipEntry) + && !Optional.ofNullable(expectedNativeLibrariesByApkPath.get(zipEntry.getName())) + .equals(Optional.of(fileHash))) { + pathsToModifiedFilesBuilder.add(zipEntry.getName()); + } + }); + return pathsToModifiedFilesBuilder.build(); + } + + private static ImmutableSet getDexFiles(CodeTransparency codeTransparency) { + return codeTransparency.getCodeRelatedFileList().stream() + .filter(codeRelatedFile -> codeRelatedFile.getType().equals(CodeRelatedFile.Type.DEX)) + .map(CodeRelatedFile::getSha256) + .collect(toImmutableSet()); + } + + private static ImmutableMap getNativeLibrariesByApkPath( + CodeTransparency codeTransparency) { + return codeTransparency.getCodeRelatedFileList().stream() + .filter( + codeRelatedFile -> + codeRelatedFile.getType().equals(CodeRelatedFile.Type.NATIVE_LIBRARY)) + .collect(toImmutableMap(CodeRelatedFile::getApkPath, CodeRelatedFile::getSha256)); + } + + private static boolean isDexFile(ZipEntry zipEntry) { + return zipEntry.getName().endsWith(".dex"); + } + + private static boolean isNativeLibrary(ZipEntry zipEntry) { + return zipEntry.getName().endsWith(".so"); + } + + private static String getFileHash(ZipFile apkFile, ZipEntry zipEntry) { + try { + return ZipUtils.asByteSource(apkFile, zipEntry).hash(Hashing.sha256()).toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private ApkTransparencyCheckUtils() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/BundleModeTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/transparency/BundleModeTransparencyChecker.java new file mode 100644 index 00000000..9c103e33 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/BundleModeTransparencyChecker.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import com.android.tools.build.bundletool.commands.CheckTransparencyCommand; +import com.android.tools.build.bundletool.model.AppBundle; +import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +/** Executes {@link CheckTransparencyCommand} in BUNDLE mode. */ +public final class BundleModeTransparencyChecker { + + public static TransparencyCheckResult checkTransparency(CheckTransparencyCommand command) { + try (ZipFile bundleZip = new ZipFile(command.getBundlePath().get().toFile())) { + AppBundle inputBundle = AppBundle.buildFromZip(bundleZip); + return BundleTransparencyCheckUtils.checkTransparency(inputBundle); + } catch (ZipException e) { + throw InvalidBundleException.builder() + .withCause(e) + .withUserMessage("The App Bundle is not a valid zip file.") + .build(); + } catch (IOException e) { + throw new UncheckedIOException("An error occurred when processing the App Bundle.", e); + } + } + + private BundleModeTransparencyChecker() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/BundleTransparencyCheckUtils.java b/src/main/java/com/android/tools/build/bundletool/transparency/BundleTransparencyCheckUtils.java new file mode 100644 index 00000000..98568a14 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/BundleTransparencyCheckUtils.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; +import com.android.tools.build.bundletool.model.AppBundle; +import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.MapDifference; +import com.google.common.collect.Maps; +import com.google.common.io.ByteSource; +import java.util.Optional; +import org.jose4j.jws.JsonWebSignature; + +/** Shared utilities for verifying code transparency in a given bundle. */ +public final class BundleTransparencyCheckUtils { + + /** + * Verifies code transparency for the given bundle, and returns {@link TransparencyCheckResult}. + * + * @throws InvalidBundleException if an error occurs during verification, or if the bundle does + * not contain code transparency file. + */ + public static TransparencyCheckResult checkTransparency(AppBundle bundle) { + Optional signedTransparencyFile = + bundle + .getBundleMetadata() + .getFileAsByteSource( + BundleMetadata.BUNDLETOOL_NAMESPACE, BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME); + if (!signedTransparencyFile.isPresent()) { + throw InvalidBundleException.builder() + .withUserMessage( + "Bundle does not include code transparency metadata. Run `add-transparency`" + + " command to add code transparency metadata to the bundle.") + .build(); + } + return checkTransparency(bundle, signedTransparencyFile.get()); + } + + /** + * Verifies code transparency for the given bundle, and returns {@link TransparencyCheckResult}. + * + * @throws InvalidBundleException if an error occurs during verification. + */ + public static TransparencyCheckResult checkTransparency( + AppBundle bundle, ByteSource signedTransparencyFile) { + if (bundle.hasSharedUserId()) { + throw InvalidBundleException.builder() + .withUserMessage( + "Transparency file is present in the bundle, but it can not be verified because" + + " `sharedUserId` attribute is specified in one of the manifests.") + .build(); + } + + TransparencyCheckResult.Builder result = TransparencyCheckResult.builder(); + + JsonWebSignature jws = CodeTransparencyCryptoUtils.parseJws(signedTransparencyFile); + if (!CodeTransparencyCryptoUtils.verifySignature(jws)) { + return result + .errorMessage("Verification failed because code transparency signature is invalid.") + .build(); + } + result + .transparencySignatureVerified(true) + .transparencyKeyCertificateFingerprint( + CodeTransparencyCryptoUtils.getCertificateFingerprint(jws)); + + MapDifference difference = + Maps.difference( + getCodeRelatedFilesFromTransparencyMetadata(jws), + getCodeRelatedFilesFromBundle(bundle)); + result.fileContentsVerified(difference.areEqual()); + if (!difference.areEqual()) { + result.errorMessage(getDiffAsString(difference)); + } + return result.build(); + } + + private static ImmutableMap getCodeRelatedFilesFromTransparencyMetadata( + JsonWebSignature signedTransparencyFile) { + return CodeTransparencyFactory.parseFrom(signedTransparencyFile.getUnverifiedPayload()) + .getCodeRelatedFileList() + .stream() + .collect(toImmutableMap(CodeRelatedFile::getPath, codeRelatedFile -> codeRelatedFile)); + } + + private static ImmutableMap getCodeRelatedFilesFromBundle( + AppBundle bundle) { + return CodeTransparencyFactory.createCodeTransparencyMetadata(bundle) + .getCodeRelatedFileList() + .stream() + .collect(toImmutableMap(CodeRelatedFile::getPath, codeRelatedFile -> codeRelatedFile)); + } + + private static String getDiffAsString( + MapDifference codeTransparencyDiff) { + if (codeTransparencyDiff.areEqual()) { + return ""; + } + return "Verification failed because code was modified after transparency metadata generation. " + + "\nFiles deleted after transparency metadata generation: " + + codeTransparencyDiff.entriesOnlyOnLeft().keySet() + + "\nFiles added after transparency metadata generation: " + + codeTransparencyDiff.entriesOnlyOnRight().keySet() + + "\nFiles modified after transparency metadata generation: " + + codeTransparencyDiff.entriesDiffering().keySet(); + } + + private BundleTransparencyCheckUtils() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyChecker.java deleted file mode 100644 index df9ef546..00000000 --- a/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyChecker.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ -package com.android.tools.build.bundletool.transparency; - -import static com.google.common.collect.ImmutableMap.toImmutableMap; - -import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; -import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; -import com.android.tools.build.bundletool.model.AppBundle; -import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; -import com.google.common.collect.ImmutableMap; -import com.google.common.io.ByteSource; -import com.google.protobuf.util.JsonFormat; -import java.io.IOException; -import java.nio.charset.Charset; -import java.security.cert.X509Certificate; -import org.jose4j.jwa.AlgorithmConstraints; -import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; -import org.jose4j.jws.AlgorithmIdentifiers; -import org.jose4j.jws.JsonWebSignature; -import org.jose4j.keys.X509Util; -import org.jose4j.lang.JoseException; - -/** Shared utilities for verifying code transparency. */ -public final class CodeTransparencyChecker { - - /** - * Verifies code transparency for the given bundle, and returns {@link TransparencyCheckResult}. - * - * @throws InvalidBundleException if an error occurs during verification. - */ - public static TransparencyCheckResult checkTransparency( - AppBundle bundle, ByteSource signedTransparencyFile) { - if (bundle.hasSharedUserId()) { - throw InvalidBundleException.builder() - .withUserMessage( - "Transparency file is present in the bundle, but it can not be verified because" - + " `sharedUserId` attribute is specified in one of the manifests.") - .build(); - } - - JsonWebSignature jws = createJws(signedTransparencyFile); - - if (verifySignature(jws)) { - return TransparencyCheckResult.createForValidSignature( - /* certificateThumbprint= */ X509Util.x5tS256(getLeafCertificate(jws)), - getCodeRelatedFilesFromTransparencyMetadata(jws), - getCodeRelatedFilesFromBundle(bundle)); - } - return TransparencyCheckResult.createForInvalidSignature(); - } - - private static JsonWebSignature createJws(ByteSource signedTransparencyFile) { - JsonWebSignature jws; - try { - jws = - (JsonWebSignature) - JsonWebSignature.fromCompactSerialization( - signedTransparencyFile.asCharSource(Charset.defaultCharset()).read()); - jws.setKey(jws.getLeafCertificateHeaderValue().getPublicKey()); - jws.setAlgorithmConstraints( - new AlgorithmConstraints( - ConstraintType.WHITELIST, AlgorithmIdentifiers.RSA_USING_SHA256)); - } catch (JoseException | IOException e) { - throw InvalidBundleException.builder() - .withUserMessage("Unable to deserialize JWS from code transparency file.") - .withCause(e) - .build(); - } - return jws; - } - - private static boolean verifySignature(JsonWebSignature jws) { - boolean signatureValid; - try { - signatureValid = jws.verifySignature(); - } catch (JoseException e) { - throw InvalidBundleException.builder() - .withUserMessage("Exception while verifying code transparency signature.") - .withCause(e) - .build(); - } - return signatureValid; - } - - private static X509Certificate getLeafCertificate(JsonWebSignature jwt) { - X509Certificate leafCertificate; - try { - leafCertificate = jwt.getLeafCertificateHeaderValue(); - } catch (JoseException e) { - throw InvalidBundleException.builder() - .withUserMessage("Unable to retrieve certificate header value from JWS.") - .withCause(e) - .build(); - } - return leafCertificate; - } - - private static ImmutableMap getCodeRelatedFilesFromTransparencyMetadata( - JsonWebSignature signedTransparencyFile) { - CodeTransparency.Builder transparencyMetadata = CodeTransparency.newBuilder(); - try { - JsonFormat.parser() - .merge(signedTransparencyFile.getUnverifiedPayload(), transparencyMetadata); - } catch (IOException e) { - throw InvalidBundleException.builder() - .withUserMessage("Unable to parse code transparency file contents.") - .withCause(e) - .build(); - } - return transparencyMetadata.getCodeRelatedFileList().stream() - .collect(toImmutableMap(CodeRelatedFile::getPath, codeRelatedFile -> codeRelatedFile)); - } - - private static ImmutableMap getCodeRelatedFilesFromBundle( - AppBundle bundle) { - return CodeTransparencyFactory.createCodeTransparencyMetadata(bundle) - .getCodeRelatedFileList() - .stream() - .collect(toImmutableMap(CodeRelatedFile::getPath, codeRelatedFile -> codeRelatedFile)); - } - - private CodeTransparencyChecker() {} -} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyCryptoUtils.java b/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyCryptoUtils.java new file mode 100644 index 00000000..241ee047 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyCryptoUtils.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import static java.util.stream.Collectors.joining; + +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; +import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteSource; +import com.google.common.primitives.Bytes; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.lang.JoseException; + +/** Helper methods related to code transparency signature parsing and verification. */ +public final class CodeTransparencyCryptoUtils { + + /** + * Parses {@link JsonWebSignature} object from the given {@link ByteSource} containing signed + * transparency file contents. + */ + public static JsonWebSignature parseJws(ByteSource signedTransparencyFile) { + JsonWebSignature jws; + try { + jws = + (JsonWebSignature) + JsonWebSignature.fromCompactSerialization( + signedTransparencyFile.asCharSource(Charset.defaultCharset()).read()); + } catch (JoseException | IOException e) { + throw CommandExecutionException.builder() + .withInternalMessage("Unable to deserialize JWS from code transparency file.") + .withCause(e) + .build(); + } + return jws; + } + + /** + * Verifies signature against the public key certificate extracted from the JWS header. Returns + * {@code true} if signature can be successfully verified. + */ + public static boolean verifySignature(JsonWebSignature jws) { + boolean signatureValid; + try { + jws.setKey(jws.getLeafCertificateHeaderValue().getPublicKey()); + jws.setAlgorithmConstraints( + new AlgorithmConstraints( + ConstraintType.WHITELIST, AlgorithmIdentifiers.RSA_USING_SHA256)); + signatureValid = jws.verifySignature(); + } catch (JoseException e) { + throw CommandExecutionException.builder() + .withInternalMessage("Exception while verifying code transparency signature.") + .withCause(e) + .build(); + } + return signatureValid; + } + + /** + * Extracts SHA-256 fingerprint of the leaf public key certificate from {@link JsonWebSignature}. + */ + public static String getCertificateFingerprint(JsonWebSignature jws) { + X509Certificate certificate; + try { + certificate = jws.getLeafCertificateHeaderValue(); + } catch (JoseException e) { + throw CommandExecutionException.builder() + .withInternalMessage("Unable to retrieve certificate header value from JWS.") + .withCause(e) + .build(); + } + return getCertificateFingerprint(certificate); + } + + /** Returns SHA-256 fingerprint of the given certificate. */ + public static String getCertificateFingerprint(X509Certificate certificate) { + return Bytes.asList(getCertificateFingerprintBytes(certificate)).stream() + .map(b -> String.format("%02X", b)) + .collect(joining(" ")); + } + + /** Reads {@link X509Certificate} from the given path. */ + public static X509Certificate getX509Certificate(Path certificatePath) { + try (InputStream inputStream = Files.newInputStream(certificatePath)) { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) certificateFactory.generateCertificate(inputStream); + } catch (IOException e) { + throw InvalidCommandException.builder() + .withInternalMessage("Unable to read public key certificate from the provided path.") + .withCause(e) + .build(); + } catch (CertificateException e) { + throw InvalidCommandException.builder() + .withInternalMessage("Unable to generate X509Certificate.") + .withCause(e) + .build(); + } + } + + private static byte[] getCertificateFingerprintBytes(X509Certificate certificate) { + byte[] certificateBytes; + try { + certificateBytes = ByteSource.wrap(certificate.getEncoded()).hash(Hashing.sha256()).asBytes(); + } catch (CertificateEncodingException | IOException e) { + throw CommandExecutionException.builder() + .withInternalMessage("Unable to get certificate fingerprint value.") + .withCause(e) + .build(); + } + return certificateBytes; + } + + private CodeTransparencyCryptoUtils() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyFactory.java b/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyFactory.java index 798e42b6..e4bef6b5 100644 --- a/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyFactory.java +++ b/src/main/java/com/android/tools/build/bundletool/transparency/CodeTransparencyFactory.java @@ -16,36 +16,52 @@ package com.android.tools.build.bundletool.transparency; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.ModuleEntry; +import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; +import com.google.common.collect.ImmutableList; import com.google.common.hash.Hashing; +import com.google.protobuf.util.JsonFormat; import java.io.IOException; import java.io.UncheckedIOException; +import java.util.Comparator; +import java.util.stream.Stream; /** Shared static utilities for adding and verifying {@link CodeTransparency}. */ public final class CodeTransparencyFactory { /** Returns {@link CodeTransparency} for the given {@link AppBundle}. */ public static CodeTransparency createCodeTransparencyMetadata(AppBundle bundle) { - CodeTransparency.Builder transparencyBuilder = CodeTransparency.newBuilder(); - bundle - .getFeatureModules() - .values() - .forEach(featureModule -> addModuleToTransparencyFile(transparencyBuilder, featureModule)); - return transparencyBuilder.build(); + ImmutableList codeRelatedFiles = + bundle.getFeatureModules().values().stream() + .flatMap(bundleModule -> getCodeRelatedFileEntries(bundleModule)) + .map(CodeTransparencyFactory::createCodeRelatedFile) + .sorted(Comparator.comparing(CodeRelatedFile::getPath)) + .collect(toImmutableList()); + return CodeTransparency.newBuilder().addAllCodeRelatedFile(codeRelatedFiles).build(); } - private static void addModuleToTransparencyFile( - CodeTransparency.Builder codeTransparencyBuilder, BundleModule module) { - module.getEntries().stream() - .filter(CodeTransparencyFactory::isCodeRelatedFile) - .forEach( - moduleEntry -> - codeTransparencyBuilder.addCodeRelatedFile(createCodeRelatedFile(moduleEntry))); + /** Returns {@link CodeTransparency} parsed from transparency file JSON payload. */ + public static CodeTransparency parseFrom(String codeTransparency) { + CodeTransparency.Builder codeTransparencyProto = CodeTransparency.newBuilder(); + try { + JsonFormat.parser().merge(codeTransparency, codeTransparencyProto); + } catch (IOException e) { + throw InvalidBundleException.builder() + .withUserMessage("Unable to parse code transparency file contents.") + .withCause(e) + .build(); + } + return codeTransparencyProto.build(); + } + + private static Stream getCodeRelatedFileEntries(BundleModule module) { + return module.getEntries().stream().filter(CodeTransparencyFactory::isCodeRelatedFile); } private static CodeRelatedFile createCodeRelatedFile(ModuleEntry moduleEntry) { diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/ConnectedDeviceModeTransparencyChecker.java b/src/main/java/com/android/tools/build/bundletool/transparency/ConnectedDeviceModeTransparencyChecker.java new file mode 100644 index 00000000..380439d0 --- /dev/null +++ b/src/main/java/com/android/tools/build/bundletool/transparency/ConnectedDeviceModeTransparencyChecker.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.transparency; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.android.tools.build.bundletool.commands.CheckTransparencyCommand; +import com.android.tools.build.bundletool.device.AdbRunner; +import com.android.tools.build.bundletool.device.AdbServer; +import com.android.tools.build.bundletool.device.AdbShellCommandTask; +import com.android.tools.build.bundletool.device.Device; +import com.android.tools.build.bundletool.device.Device.FilePullParams; +import com.android.tools.build.bundletool.device.DeviceAnalyzer; +import com.android.tools.build.bundletool.io.TempDirectory; +import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.UncheckedTimeoutException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.concurrent.TimeoutException; + +/** Executes {@link CheckTransparencyCommand} in CONNECTED_DEVICE mode. */ +public final class ConnectedDeviceModeTransparencyChecker { + + private static final String APK_PATH_ON_DEVICE_PREFIX = "package:/"; + + public static TransparencyCheckResult checkTransparency(CheckTransparencyCommand command) { + command.getAdbServer().get().init(command.getAdbPath().get()); + AdbRunner adbRunner = new AdbRunner(command.getAdbServer().get()); + Device adbDevice = getDevice(command.getAdbServer().get(), command.getDeviceId()); + + // Execute a shell command to retrieve paths to all APKs for the given package name. + AdbShellCommandTask adbShellCommandTask = + new AdbShellCommandTask(adbDevice, "pm path " + command.getPackageName().get()); + ImmutableList pathsToApksOnDevice = + adbShellCommandTask.execute().stream() + .filter(path -> path.startsWith(APK_PATH_ON_DEVICE_PREFIX)) + .map(path -> path.substring(APK_PATH_ON_DEVICE_PREFIX.length())) + .collect(toImmutableList()); + if (pathsToApksOnDevice.isEmpty()) { + throw InvalidCommandException.builder() + .withInternalMessage("No files found for package " + command.getPackageName().get()) + .build(); + } + + // Pull APKs to a temporary directory and verify code transparency. + try (TempDirectory tempDir = new TempDirectory("connected-device-transparency-check")) { + Path apksExtractedSubDirectory = tempDir.getPath().resolve("extracted"); + Files.createDirectory(apksExtractedSubDirectory); + ImmutableList pullParams = + createPullParams(pathsToApksOnDevice, apksExtractedSubDirectory); + + if (command.getDeviceId().isPresent()) { + adbRunner.run(device -> device.pull(pullParams), command.getDeviceId().get()); + } else { + adbRunner.run(device -> device.pull(pullParams)); + } + + return ApkTransparencyCheckUtils.checkTransparency( + pullParams.stream().map(FilePullParams::getDestinationPath).collect(toImmutableList())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static Device getDevice(AdbServer adbServer, Optional deviceId) { + DeviceAnalyzer deviceAnalyzer = new DeviceAnalyzer(adbServer); + Device device; + try { + device = deviceAnalyzer.getAndValidateDevice(deviceId); + } catch (TimeoutException e) { + throw new UncheckedTimeoutException(e); + } + return device; + } + + private static ImmutableList createPullParams( + ImmutableList pathsToApksOnDevice, Path apksExtractedSubDirectory) { + return pathsToApksOnDevice.stream() + .map( + pathOnDevice -> + FilePullParams.builder() + .setPathOnDevice(pathOnDevice) + .setDestinationPath( + apksExtractedSubDirectory.resolve(Paths.get(pathOnDevice).getFileName())) + .build()) + .collect(toImmutableList()); + } + + private ConnectedDeviceModeTransparencyChecker() {} +} diff --git a/src/main/java/com/android/tools/build/bundletool/transparency/TransparencyCheckResult.java b/src/main/java/com/android/tools/build/bundletool/transparency/TransparencyCheckResult.java index fda62e65..4f473d54 100644 --- a/src/main/java/com/android/tools/build/bundletool/transparency/TransparencyCheckResult.java +++ b/src/main/java/com/android/tools/build/bundletool/transparency/TransparencyCheckResult.java @@ -16,83 +16,95 @@ package com.android.tools.build.bundletool.transparency; -import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; import com.google.auto.value.AutoValue; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.MapDifference; -import com.google.common.collect.Maps; import java.util.Optional; /** Represents result of code transparency verification. */ @AutoValue public abstract class TransparencyCheckResult { + /** Whether code transparency signature was successfully verified. */ + abstract boolean transparencySignatureVerified(); + + /** Whether code transparency file contents were successfully verified. */ + abstract boolean fileContentsVerified(); + /** - * Thumbprint of the public key certificate used to successfully verify transparency. Only set if - * code transparency signature was successfully verified. + * SHA-256 Fingerprint of the public key certificate that was used for verifying code transparency + * signature. Only set when {@link #transparencySignatureVerified()} is true. */ - public abstract Optional certificateThumbprint(); + public abstract Optional transparencyKeyCertificateFingerprint(); /** - * {@link CodeRelatedFile}s extracted from the transparency metadata, keyed by path. Only set if - * transparency signature was successfully verified. + * SHA-256 Fingerprint of the public key certificate corresponding to the APK signature. Only set + * if the transparency was verified in CONNECTED_DEVICE or APK mode. Empty in BUNDLE mode and when + * APK signature verification fails. */ - abstract ImmutableMap codeRelatedFilesFromTransparencyMetadata(); + public abstract Optional apkSigningKeyCertificateFingerprint(); /** - * {@link CodeRelatedFile}s extracted from the bundle or APKs, keyed by path. Only set if - * transparency signature was successfully verified. + * Error message containint information about the cause of code transparency verification failure. + * Only set when code transparency verification fails. */ - abstract ImmutableMap actualCodeRelatedFiles(); + public abstract Optional errorMessage(); + + /** Returns true if code transparency signature and file contents were successfully verified. */ + public boolean verified() { + return transparencySignatureVerified() && fileContentsVerified(); + } /** - * Difference between {@link #codeRelatedFilesFromTransparencyMetadata()} and {@link - * #actualCodeRelatedFiles()}. + * Returns SHA-256 fingerprint of the public key certificate that was used to successfully verify + * transparency signature, or an empty string if verification failed. */ - abstract MapDifference codeTransparencyDiff(); - - /** Returns {@code true} if code transparency signature was successfully verified. */ - public boolean signatureVerified() { - return certificateThumbprint().isPresent(); + public String getTransparencyKeyCertificateFingerprint() { + return transparencyKeyCertificateFingerprint().orElse(""); } /** - * Returns {@code true} if the code transparency file contents match actual code related files - * present in the bundle or APKs. + * Returns SHA-256 fingerprint of the APK signing key certificate. Returns an empty string if the + * fingerprint is not set. */ - public boolean fileContentsVerified() { - return signatureVerified() && codeTransparencyDiff().areEqual(); + public String getApkSigningKeyCertificateFingerprint() { + return apkSigningKeyCertificateFingerprint().orElse(""); } /** - * Returns string representation of difference between code transparency file contents and code - * related files present in the bundle or the APKs. + * Returns an error message containing information about the cause of code transparency + * verification failure, or an empty string if code transparency was successfully verified. */ - public String getDiffAsString() { - return "Files deleted after transparency metadata generation: " - + codeTransparencyDiff().entriesOnlyOnLeft().keySet() - + "\nFiles added after transparency metadata generation: " - + codeTransparencyDiff().entriesOnlyOnRight().keySet() - + "\nFiles modified after transparency metadata generation: " - + codeTransparencyDiff().entriesDiffering().keySet(); + public String getErrorMessage() { + return errorMessage().orElse(""); + } + + /** Creates a builder for TransparencyCheckResult. */ + public static Builder builder() { + return new AutoValue_TransparencyCheckResult.Builder() + .transparencySignatureVerified(false) + .fileContentsVerified(false); } - static TransparencyCheckResult createForValidSignature( - String certificateThumbprint, - ImmutableMap codeRelatedFilesFromTransparencyMetadata, - ImmutableMap actualCodeRelatedFiles) { - return new AutoValue_TransparencyCheckResult( - Optional.of(certificateThumbprint), - codeRelatedFilesFromTransparencyMetadata, - actualCodeRelatedFiles, - Maps.difference(codeRelatedFilesFromTransparencyMetadata, actualCodeRelatedFiles)); + /** Returns empty TransparencyCheckResult. */ + public static TransparencyCheckResult empty() { + return builder().build(); } - static TransparencyCheckResult createForInvalidSignature() { - return new AutoValue_TransparencyCheckResult( - /* certificateThumbprint= */ Optional.empty(), - /* codeRelatedFilesFromTransparencyMetadata= */ ImmutableMap.of(), - /* actualCodeRelatedFiles= */ ImmutableMap.of(), - /* codeTransparencyDiff= */ Maps.difference(ImmutableMap.of(), ImmutableMap.of())); + /** Builder for TransparencyCheckResult. */ + @AutoValue.Builder + public abstract static class Builder { + + public abstract Builder transparencySignatureVerified(boolean transparencySignatureVerified); + + public abstract Builder fileContentsVerified(boolean fileContentsVerified); + + public abstract Builder transparencyKeyCertificateFingerprint( + String transparencyKeyCertificateFingerprint); + + public abstract Builder apkSigningKeyCertificateFingerprint( + String apkSigningKeyCertificateFingerprint); + + public abstract Builder errorMessage(String errorMessage); + + public abstract TransparencyCheckResult build(); } } diff --git a/src/main/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidator.java b/src/main/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidator.java index 9c33e5aa..3e81ef9f 100644 --- a/src/main/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidator.java +++ b/src/main/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidator.java @@ -16,7 +16,7 @@ package com.android.tools.build.bundletool.validation; -import static com.android.tools.build.bundletool.transparency.CodeTransparencyChecker.checkTransparency; +import static com.android.tools.build.bundletool.transparency.BundleTransparencyCheckUtils.checkTransparency; import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleMetadata; @@ -40,17 +40,9 @@ public void validateBundle(AppBundle bundle) { } TransparencyCheckResult transparencyCheckResult = checkTransparency(bundle, signedTransparencyFile.get()); - if (!transparencyCheckResult.signatureVerified()) { + if (!transparencyCheckResult.verified()) { throw InvalidBundleException.builder() - .withUserMessage("Code transparency verification failed because signature is invalid.") - .build(); - } - if (!transparencyCheckResult.fileContentsVerified()) { - throw InvalidBundleException.builder() - .withUserMessage( - "Code transparency verification failed because code was modified " - + "after transparency metadata generation.\n" - + transparencyCheckResult.getDiffAsString()) + .withUserMessage(transparencyCheckResult.getErrorMessage()) .build(); } } diff --git a/src/main/java/com/android/tools/build/bundletool/validation/ModuleNamesValidator.java b/src/main/java/com/android/tools/build/bundletool/validation/ModuleNamesValidator.java index 2d0618ce..0b9ee88f 100644 --- a/src/main/java/com/android/tools/build/bundletool/validation/ModuleNamesValidator.java +++ b/src/main/java/com/android/tools/build/bundletool/validation/ModuleNamesValidator.java @@ -16,7 +16,6 @@ package com.android.tools.build.bundletool.validation; import com.android.tools.build.bundletool.model.BundleModule; -import com.android.tools.build.bundletool.model.BundleModule.ModuleType; import com.android.tools.build.bundletool.model.BundleModuleName; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; import com.google.common.collect.ImmutableList; @@ -39,8 +38,7 @@ public void validateAllModules(ImmutableList modules) { for (BundleModule module : modules) { Optional splitId = module.getAndroidManifest().getSplitId(); BundleModuleName moduleName = module.getName(); - boolean isFeatureModule = - module.getAndroidManifest().getModuleType().equals(ModuleType.FEATURE_MODULE); + boolean isFeatureModule = module.getAndroidManifest().getModuleType().isFeatureModule(); if (moduleName.equals(BundleModuleName.BASE_MODULE_NAME)) { if (splitId.isPresent()) { diff --git a/src/main/proto/commands.proto b/src/main/proto/commands.proto index daeb8ed1..d3a1cbb3 100644 --- a/src/main/proto/commands.proto +++ b/src/main/proto/commands.proto @@ -54,6 +54,15 @@ message Variant { message ExtractApksResult { // Set of extracted APKs. repeated ExtractedApk apks = 1; + + // Information about the APKs if built with local testing enabled. + LocalTestingInfoForMetadata local_testing_info = 2; +} + +message LocalTestingInfoForMetadata { + // The absolute path on the device that files targeted by local testing + // mode will be pushed to. + string local_testing_dir = 1; } // Describes extracted APK. @@ -81,6 +90,9 @@ message ModuleMetadata { // Module name. string name = 1; + // Indicates the type of this feature module. + FeatureModuleType module_type = 7; + // Indicates the delivery type (e.g. on-demand) of the module. DeliveryType delivery_type = 6; @@ -140,6 +152,12 @@ enum DeliveryType { FAST_FOLLOW = 3; } +enum FeatureModuleType { + UNKNOWN_MODULE_TYPE = 0; + FEATURE_MODULE = 1; + ML_MODULE = 2; +} + message ApkDescription { ApkTargeting targeting = 1; diff --git a/src/main/proto/devices.proto b/src/main/proto/devices.proto index 91a5e0a4..7c0c2c33 100644 --- a/src/main/proto/devices.proto +++ b/src/main/proto/devices.proto @@ -30,4 +30,7 @@ message DeviceSpec { // Device tier. string device_tier = 8; + + // Device groups the device belongs to. + repeated string device_groups = 9; } diff --git a/src/main/proto/rotation_config.proto b/src/main/proto/rotation_config.proto new file mode 100644 index 00000000..267677d9 --- /dev/null +++ b/src/main/proto/rotation_config.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package android.bundle; + +option java_package = "com.android.bundle"; + +// Specifies the config that gets applied to the rotation aspect of the signing +// process of the App Bundle. +// Next tag: 2 +message RotationConfig { + // The SHA256 fingerprint of the expected certificate to sign the APKs + // generated from the Bundle. + // Example: + // FE:C0:E6:5B:F3:76:5D:A1:C2:56:13:C7:A3:60:35:A9:26:BC:3B:3A:39:9B:C8:55:40:F1:6D:55:17:3F:F5:9B + string signing_certificate_sha256_fingerprint = 1; +} diff --git a/src/main/proto/targeting.proto b/src/main/proto/targeting.proto index e78badad..c7b0d863 100644 --- a/src/main/proto/targeting.proto +++ b/src/main/proto/targeting.proto @@ -34,7 +34,8 @@ message ModuleTargeting { SdkVersionTargeting sdk_version_targeting = 1; repeated DeviceFeatureTargeting device_feature_targeting = 2; UserCountriesTargeting user_countries_targeting = 3; - DeviceTierModuleTargeting device_tier_targeting = 4; + DeviceTierModuleTargeting device_tier_targeting = 4 [deprecated = true]; + DeviceGroupModuleTargeting device_group_targeting = 5; } // User Countries targeting describing an inclusive/exclusive list of country @@ -206,3 +207,8 @@ message DeviceTierTargeting { message DeviceTierModuleTargeting { repeated string value = 1; } + +// Targets conditional modules to a set of device groups. +message DeviceGroupModuleTargeting { + repeated string value = 1; +} diff --git a/src/test/java/com/android/tools/build/bundletool/commands/AddTransparencyCommandTest.java b/src/test/java/com/android/tools/build/bundletool/commands/AddTransparencyCommandTest.java index f2a3a8e3..538baf9b 100644 --- a/src/test/java/com/android/tools/build/bundletool/commands/AddTransparencyCommandTest.java +++ b/src/test/java/com/android/tools/build/bundletool/commands/AddTransparencyCommandTest.java @@ -16,8 +16,11 @@ package com.android.tools.build.bundletool.commands; import static com.android.tools.build.bundletool.commands.AddTransparencyCommand.MIN_RSA_KEY_LENGTH; +import static com.android.tools.build.bundletool.commands.AddTransparencyCommand.createJwtWithoutSignature; import static com.android.tools.build.bundletool.model.BundleMetadata.BUNDLETOOL_NAMESPACE; +import static com.android.tools.build.bundletool.testing.CodeTransparencyTestUtils.createJwsToken; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withMinSdkVersion; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withSharedUserId; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth8.assertThat; @@ -28,6 +31,8 @@ import com.android.aapt.Resources.XmlNode; import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; +import com.android.tools.build.bundletool.commands.AddTransparencyCommand.DexMergingChoice; +import com.android.tools.build.bundletool.commands.AddTransparencyCommand.Mode; import com.android.tools.build.bundletool.flags.Flag.RequiredFlagNotSetException; import com.android.tools.build.bundletool.flags.FlagParser; import com.android.tools.build.bundletool.flags.ParsedFlags.UnknownFlagsException; @@ -37,33 +42,44 @@ import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.Password; import com.android.tools.build.bundletool.model.SignerConfig; +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; +import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; import com.android.tools.build.bundletool.testing.AppBundleBuilder; import com.android.tools.build.bundletool.testing.BundleModuleBuilder; import com.android.tools.build.bundletool.testing.CertificateFactory; +import com.android.tools.build.bundletool.transparency.CodeTransparencyFactory; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; import com.google.common.hash.Hashing; +import com.google.common.io.BaseEncoding; import com.google.common.io.ByteSource; +import com.google.common.io.CharSource; import com.google.protobuf.util.JsonFormat; import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; import java.security.PrivateKey; +import java.security.Signature; import java.security.cert.Certificate; +import java.util.List; import java.util.Optional; import java.util.zip.ZipFile; import org.jose4j.jws.JsonWebSignature; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -@RunWith(JUnit4.class) +@RunWith(Theories.class) public final class AddTransparencyCommandTest { private static final String BASE_MODULE = "base"; @@ -87,8 +103,11 @@ public final class AddTransparencyCommandTest { private Path tmpDir; private Path bundlePath; - private Path outputPath; + private Path outputBundlePath; + private Path outputUnsignedTransparencyFilePath; private Path keystorePath; + private Path transparencyKeyCertificatePath; + private Path transparencySignatureFilePath; private SignerConfig signerConfig; private String fileHashDex1; private String fileHashDex2; @@ -99,9 +118,13 @@ public final class AddTransparencyCommandTest { public void setUp() throws Exception { tmpDir = tmp.getRoot().toPath(); bundlePath = tmpDir.resolve("bundle.aab"); - outputPath = tmpDir.resolve("bundle_with_transparency.aab"); + outputBundlePath = tmpDir.resolve("bundle_with_transparency.aab"); + outputUnsignedTransparencyFilePath = tmpDir.resolve("transparency_file.jwe"); + transparencySignatureFilePath = tmpDir.resolve("signature.sig"); keystorePath = tmpDir.resolve("keystore.jks"); createRsaKeySignerConfig(keystorePath, MIN_RSA_KEY_LENGTH); + transparencyKeyCertificatePath = tmpDir.resolve("transparency-public.cert"); + Files.write(transparencyKeyCertificatePath, signerConfig.getCertificates().get(0).getEncoded()); fileHashDex1 = ByteSource.wrap(FILE_CONTENT_DEX1).hash(Hashing.sha256()).toString(); fileHashDex2 = ByteSource.wrap(FILE_CONTENT_DEX2).hash(Hashing.sha256()).toString(); fileHashNativeLib1 = @@ -111,7 +134,7 @@ public void setUp() throws Exception { } @Test - public void buildingCommandViaFlags_bundlePathNotSet() throws Exception { + public void buildingCommandViaFlags_defaultMode_bundlePathNotSet() { Throwable e = assertThrows( RequiredFlagNotSetException.class, @@ -119,7 +142,7 @@ public void buildingCommandViaFlags_bundlePathNotSet() throws Exception { AddTransparencyCommand.fromFlags( new FlagParser() .parse( - "--output=" + outputPath, + "--output=" + outputBundlePath, "--ks=" + keystorePath, "--ks-key-alias=" + KEY_ALIAS, "--ks-pass=pass:" + KEYSTORE_PASSWORD, @@ -128,7 +151,7 @@ public void buildingCommandViaFlags_bundlePathNotSet() throws Exception { } @Test - public void buildingCommandViaFlags_outputPathNotSet() throws Exception { + public void buildingCommandViaFlags_defaultMode_outputPathNotSet() { Throwable e = assertThrows( RequiredFlagNotSetException.class, @@ -145,7 +168,7 @@ public void buildingCommandViaFlags_outputPathNotSet() throws Exception { } @Test - public void buildingCommandViaFlags_keystoreNotSet() { + public void buildingCommandViaFlags_defaultMode_keystoreNotSet() { Throwable e = assertThrows( RequiredFlagNotSetException.class, @@ -154,7 +177,7 @@ public void buildingCommandViaFlags_keystoreNotSet() { new FlagParser() .parse( "--bundle=" + bundlePath, - "--output=" + outputPath, + "--output=" + outputBundlePath, "--ks-key-alias=" + KEY_ALIAS, "--ks-pass=pass:" + KEYSTORE_PASSWORD, "--key-pass=pass:" + KEY_PASSWORD))); @@ -162,7 +185,7 @@ public void buildingCommandViaFlags_keystoreNotSet() { } @Test - public void buildingCommandViaFlags_keyAliasNotSet() { + public void buildingCommandViaFlags_defaultMode_keyAliasNotSet() { Throwable e = assertThrows( RequiredFlagNotSetException.class, @@ -171,7 +194,7 @@ public void buildingCommandViaFlags_keyAliasNotSet() { new FlagParser() .parse( "--bundle=" + bundlePath, - "--output=" + outputPath, + "--output=" + outputBundlePath, "--ks=" + keystorePath, "--ks-pass=pass:" + KEYSTORE_PASSWORD, "--key-pass=pass:" + KEY_PASSWORD))); @@ -179,7 +202,7 @@ public void buildingCommandViaFlags_keyAliasNotSet() { } @Test - public void buildingCommandViaFlags_unknownFlag() throws Exception { + public void buildingCommandViaFlags_defaultMode_unknownFlag() { Throwable e = assertThrows( UnknownFlagsException.class, @@ -188,7 +211,7 @@ public void buildingCommandViaFlags_unknownFlag() throws Exception { new FlagParser() .parse( "--bundle=" + bundlePath, - "--output=" + outputPath, + "--output=" + outputBundlePath, "--ks=" + keystorePath, "--ks-key-alias=" + KEY_ALIAS, "--ks-pass=pass:" + KEYSTORE_PASSWORD, @@ -198,21 +221,60 @@ public void buildingCommandViaFlags_unknownFlag() throws Exception { } @Test - public void buildingCommandViaFlagsAndBuilderHasSameResult() throws Exception { + public void buildingCommandViaFlags_defaultMode_transparencySignatureFlagSet() { + Throwable e = + assertThrows( + UnknownFlagsException.class, + () -> + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--bundle=" + bundlePath, + "--output=" + outputBundlePath, + "--ks=" + keystorePath, + "--ks-key-alias=" + KEY_ALIAS, + "--ks-pass=pass:" + KEYSTORE_PASSWORD, + "--key-pass=pass:" + KEY_PASSWORD, + "--transparency-signature=" + transparencySignatureFilePath))); + assertThat(e).hasMessageThat().contains("Unrecognized flags"); + } + + @Test + public void buildingCommandViaFlags_defaultMode_transparencyKeyCertificateFlagSet() { + Throwable e = + assertThrows( + UnknownFlagsException.class, + () -> + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--bundle=" + bundlePath, + "--output=" + outputBundlePath, + "--ks=" + keystorePath, + "--ks-key-alias=" + KEY_ALIAS, + "--ks-pass=pass:" + KEYSTORE_PASSWORD, + "--key-pass=pass:" + KEY_PASSWORD, + "--transparency-key-certificate=" + transparencyKeyCertificatePath))); + assertThat(e).hasMessageThat().contains("Unrecognized flags"); + } + + @Test + public void buildingCommandViaFlagsAndBuilderHasSameResult_defaultMode() { AddTransparencyCommand commandViaFlags = AddTransparencyCommand.fromFlags( new FlagParser() .parse( "--bundle=" + bundlePath, - "--output=" + outputPath, + "--output=" + outputBundlePath, "--ks=" + keystorePath, "--ks-key-alias=" + KEY_ALIAS, "--ks-pass=pass:" + KEYSTORE_PASSWORD, "--key-pass=pass:" + KEY_PASSWORD)); AddTransparencyCommand commandViaBuilder = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); @@ -220,11 +282,149 @@ public void buildingCommandViaFlagsAndBuilderHasSameResult() throws Exception { } @Test - public void execute_bundleNotFound() throws Exception { + public void buildingCommandViaFlagsAndBuilderHasSameResult_generateCodeTransparencyFileMode() { + AddTransparencyCommand commandViaFlags = + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=generate_code_transparency_file", + "--bundle=" + bundlePath, + "--output=" + outputUnsignedTransparencyFilePath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath)); + AddTransparencyCommand commandViaBuilder = + AddTransparencyCommand.builder() + .setMode(Mode.GENERATE_CODE_TRANSPARENCY_FILE) + .setBundlePath(bundlePath) + .setOutputPath(outputUnsignedTransparencyFilePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void buildingCommandViaFlags_generateCodeTransparencyFileMode_missingRequiredFlag() { + Throwable e = + assertThrows( + RequiredFlagNotSetException.class, + () -> + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=generate_code_transparency_file", + "--bundle=" + bundlePath, + "--output=" + outputUnsignedTransparencyFilePath))); + assertThat(e) + .hasMessageThat() + .contains("Missing the required --transparency-key-certificate flag"); + } + + @Test + public void buildingCommandViaFlags_generateCodeTransparencyFileMode_unrecognizedFlag() { + Throwable e = + assertThrows( + UnknownFlagsException.class, + () -> + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=generate_code_transparency_file", + "--bundle=" + bundlePath, + "--output=" + outputUnsignedTransparencyFilePath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath, + "--ks=" + keystorePath))); + assertThat(e).hasMessageThat().contains("Unrecognized flags"); + } + + @Test + public void buildingCommandViaFlagsAndBuilderHasSameResult_injectSignatureMode() { + AddTransparencyCommand commandViaFlags = + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=inject_signature", + "--bundle=" + bundlePath, + "--output=" + outputBundlePath, + "--transparency-signature=" + transparencySignatureFilePath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath)); + AddTransparencyCommand commandViaBuilder = + AddTransparencyCommand.builder() + .setMode(Mode.INJECT_SIGNATURE) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setTransparencySignaturePath(transparencySignatureFilePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void buildingCommandViaFlags_injectSignatureMode_missingRequiredFlag() { + Throwable e = + assertThrows( + RequiredFlagNotSetException.class, + () -> + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=inject_signature", + "--bundle=" + bundlePath, + "--output=" + outputBundlePath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath))); + assertThat(e).hasMessageThat().contains("Missing the required --transparency-signature flag"); + } + + @Test + public void buildingCommandViaFlags_injectSignatureMode_unrecognizedFlag() { + Throwable e = + assertThrows( + UnknownFlagsException.class, + () -> + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=inject_signature", + "--bundle=" + bundlePath, + "--output=" + outputBundlePath, + "--transparency-signature=" + transparencySignatureFilePath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath, + "--ks=" + keystorePath))); + assertThat(e).hasMessageThat().contains("Unrecognized flags"); + } + + @Test + @Theory + public void buildingCommandViaFlags_dexMergingChoice(DexMergingChoice dexMergingChoice) { + AddTransparencyCommand commandViaFlags = + AddTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--dex-merging-choice=" + dexMergingChoice.getLowerCaseName(), + "--bundle=" + bundlePath, + "--output=" + outputBundlePath, + "--ks=" + keystorePath, + "--ks-key-alias=" + KEY_ALIAS, + "--ks-pass=pass:" + KEYSTORE_PASSWORD, + "--key-pass=pass:" + KEY_PASSWORD)); + AddTransparencyCommand commandViaBuilder = + AddTransparencyCommand.builder() + .setDexMergingChoice(dexMergingChoice) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setSignerConfig(signerConfig) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void execute_defaultMode_bundleNotFound() { AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); @@ -233,12 +433,13 @@ public void execute_bundleNotFound() throws Exception { } @Test - public void execute_wrongInputFileFormat() throws Exception { + public void execute_defaultMode_wrongInputFileFormat() { AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(tmpDir.resolve("bundle.txt")) .setSignerConfig(signerConfig) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .build(); Throwable e = assertThrows(IllegalArgumentException.class, addTransparencyCommand::execute); @@ -246,10 +447,11 @@ public void execute_wrongInputFileFormat() throws Exception { } @Test - public void execute_wrongOutputFileFormat() throws Exception { + public void execute_defaultMode_wrongOutputFileFormat() throws Exception { createBundle(bundlePath); AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) .setOutputPath(tmpDir.resolve("bundle.txt")) .setSignerConfig(signerConfig) @@ -260,13 +462,14 @@ public void execute_wrongOutputFileFormat() throws Exception { } @Test - public void execute_outputFileAlreadyExists() throws Exception { + public void execute_defaultMode_outputFileAlreadyExists() throws Exception { createBundle(bundlePath); - createBundle(outputPath); + createBundle(outputBundlePath); AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); @@ -275,12 +478,13 @@ public void execute_outputFileAlreadyExists() throws Exception { } @Test - public void execute_sharedUserIdSpecifiedInManifest() throws Exception { + public void execute_defaultMode_sharedUserIdSpecifiedInManifest() throws Exception { createBundle(bundlePath, /* hasSharedUserId= */ true); AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); @@ -293,13 +497,14 @@ public void execute_sharedUserIdSpecifiedInManifest() throws Exception { } @Test - public void execute_unsupportedKeyLength() throws Exception { + public void execute_defaultMode_unsupportedKeyLength() throws Exception { createBundle(bundlePath); createRsaKeySignerConfig(keystorePath, /* keySize= */ 1024); AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); @@ -310,13 +515,14 @@ public void execute_unsupportedKeyLength() throws Exception { } @Test - public void execute_unsupportedAlgorithm() throws Exception { + public void execute_defaultMode_unsupportedAlgorithm() throws Exception { createBundle(bundlePath); createSignerConfigWithUnsupportedAlgorithm(keystorePath, /* keySize= */ 1024); AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); @@ -327,18 +533,19 @@ public void execute_unsupportedAlgorithm() throws Exception { } @Test - public void execute_success() throws Exception { + public void execute_defaultMode_success() throws Exception { createBundle(bundlePath); AddTransparencyCommand addTransparencyCommand = AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) .setBundlePath(bundlePath) - .setOutputPath(outputPath) + .setOutputPath(outputBundlePath) .setSignerConfig(signerConfig) .build(); addTransparencyCommand.execute(); - AppBundle outputBundle = AppBundle.buildFromZip(new ZipFile(outputPath.toFile())); + AppBundle outputBundle = AppBundle.buildFromZip(new ZipFile(outputBundlePath.toFile())); Optional signedTransparencyFile = outputBundle .getBundleMetadata() @@ -354,9 +561,278 @@ public void execute_success() throws Exception { // jws.getPayload method will do signature verification using the public key set below. jws.setKey(signerConfig.getCertificates().get(0).getPublicKey()); CodeTransparency transparencyProto = getTransparencyProto(jws.getPayload()); - assertThat(transparencyProto) - .ignoringRepeatedFieldOrder() - .isEqualTo(expectedTransparencyProto()); + assertThat(transparencyProto).isEqualTo(expectedTransparencyProto()); + } + + @Test + public void execute_defaultMode_dexMergingChoiceContinue_success() throws Exception { + createBundle(bundlePath, /* hasSharedUserId= */ false, /* minSdkVersion= */ 19); + AddTransparencyCommand addTransparencyCommand = + AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) + .setDexMergingChoice(DexMergingChoice.CONTINUE) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setSignerConfig(signerConfig) + .build(); + + addTransparencyCommand.execute(); + + AppBundle outputBundle = AppBundle.buildFromZip(new ZipFile(outputBundlePath.toFile())); + Optional signedTransparencyFile = + outputBundle + .getBundleMetadata() + .getFileAsByteSource( + BUNDLETOOL_NAMESPACE, BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME); + assertThat(signedTransparencyFile).isPresent(); + } + + @Test + public void execute_defaultMode_dexMergingChoiceReject_fail() throws Exception { + createBundle(bundlePath, /* hasSharedUserId= */ false, /* minSdkVersion= */ 19); + AddTransparencyCommand addTransparencyCommand = + AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) + .setDexMergingChoice(DexMergingChoice.REJECT) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setSignerConfig(signerConfig) + .build(); + + InvalidCommandException exception = + assertThrows(InvalidCommandException.class, addTransparencyCommand::execute); + assertThat(exception).hasMessageThat().contains("'add-transparency' command is rejected"); + } + + @Test + public void execute_generateCodeTransparencyFileMode() throws Exception { + createBundle(bundlePath); + AddTransparencyCommand addTransparencyCommand = + AddTransparencyCommand.builder() + .setMode(Mode.GENERATE_CODE_TRANSPARENCY_FILE) + .setBundlePath(bundlePath) + .setOutputPath(outputUnsignedTransparencyFilePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .build(); + + addTransparencyCommand.execute(); + + List outputFileLines = Files.readAllLines(outputUnsignedTransparencyFilePath); + assertThat(outputFileLines).hasSize(1); + String unsignedJwt = outputFileLines.get(0); + ImmutableList jwtComponents = ImmutableList.copyOf(Splitter.on(".").split(unsignedJwt)); + assertThat(jwtComponents).hasSize(2); + String expectedFinalJws = + createJwsToken( + expectedTransparencyProto(), + signerConfig.getCertificates().get(0), + signerConfig.getPrivateKey(), + RSA_USING_SHA256); + ImmutableList expectedFinalJwtComponents = + ImmutableList.copyOf(Splitter.on(".").split(expectedFinalJws)); + assertThat(jwtComponents.get(0)).isEqualTo(expectedFinalJwtComponents.get(0)); + CodeTransparency.Builder actualCodeTransparencyContents = CodeTransparency.newBuilder(); + JsonFormat.parser() + .merge( + ByteSource.wrap(BaseEncoding.base64().decode(jwtComponents.get(1))) + .asCharSource(Charset.defaultCharset()) + .read(), + actualCodeTransparencyContents); + assertThat(actualCodeTransparencyContents.build()).isEqualTo(expectedTransparencyProto()); + } + + @Test + public void execute_generateCodeTransparencyFileMode_unsupportedAlgorithm() throws Exception { + createBundle(bundlePath); + createSignerConfigWithUnsupportedAlgorithm(keystorePath, /* keySize= */ 1024); + AddTransparencyCommand addTransparencyCommand = + AddTransparencyCommand.builder() + .setMode(Mode.GENERATE_CODE_TRANSPARENCY_FILE) + .setBundlePath(bundlePath) + .setOutputPath(outputUnsignedTransparencyFilePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .build(); + + Throwable e = assertThrows(IllegalArgumentException.class, addTransparencyCommand::execute); + assertThat(e) + .hasMessageThat() + .isEqualTo("Transparency signing key must be an RSA key, but DSA key was provided."); + } + + @Test + public void execute_generateCodeTransparencyFileMode_unsupportedKeyLength() throws Exception { + createBundle(bundlePath); + createRsaKeySignerConfig(keystorePath, /* keySize= */ 2048); + AddTransparencyCommand addTransparencyCommand = + AddTransparencyCommand.builder() + .setMode(Mode.GENERATE_CODE_TRANSPARENCY_FILE) + .setBundlePath(bundlePath) + .setOutputPath(outputUnsignedTransparencyFilePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .build(); + + Throwable e = assertThrows(IllegalArgumentException.class, addTransparencyCommand::execute); + assertThat(e) + .hasMessageThat() + .isEqualTo("Minimum required key length is 3072 bits, but 2048 bit key was provided."); + } + + @Test + public void execute_injectSignature() throws Exception { + // create bundle. + createBundle(bundlePath); + // add transparency file in default mode. + Path tmpOutputBundlePath = tmpDir.resolve("tmp_output_bundle.aab"); + AddTransparencyCommand.builder() + .setMode(Mode.DEFAULT) + .setBundlePath(bundlePath) + .setOutputPath(tmpOutputBundlePath) + .setSignerConfig(signerConfig) + .build() + .execute(); + // get the correct transparency signature bytes. + AppBundle tmpOutputBundle = AppBundle.buildFromZip(new ZipFile(tmpOutputBundlePath.toFile())); + ByteSource signedTransparencyFile = + tmpOutputBundle + .getBundleMetadata() + .getFileAsByteSource(BUNDLETOOL_NAMESPACE, BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME) + .get(); + String jws = signedTransparencyFile.asCharSource(Charset.defaultCharset()).read(); + String signature = ImmutableList.copyOf(Splitter.on(".").split(jws)).get(2); + byte[] signatureBytes = BaseEncoding.base64Url().decode(signature); + Files.write(transparencySignatureFilePath, signatureBytes); + + // inject signature into the original bundle + AddTransparencyCommand.builder() + .setMode(Mode.INJECT_SIGNATURE) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .setTransparencySignaturePath(transparencySignatureFilePath) + .build() + .execute(); + + // verify that the output bundle contains signed code transparency metadata. + AppBundle outputBundle = AppBundle.buildFromZip(new ZipFile(outputBundlePath.toFile())); + Optional finalSignedTransparencyFile = + outputBundle + .getBundleMetadata() + .getFileAsByteSource( + BUNDLETOOL_NAMESPACE, BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME); + assertThat(finalSignedTransparencyFile).isPresent(); + JsonWebSignature finalJws = + (JsonWebSignature) + JsonWebSignature.fromCompactSerialization( + finalSignedTransparencyFile.get().asCharSource(Charset.defaultCharset()).read()); + assertThat(finalJws.getAlgorithmHeaderValue()).isEqualTo(RSA_USING_SHA256); + assertThat(finalJws.getCertificateChainHeaderValue()).isEqualTo(signerConfig.getCertificates()); + // jws.getPayload method will do signature verification using the public key set below. + finalJws.setKey(signerConfig.getCertificates().get(0).getPublicKey()); + CodeTransparency transparencyProto = getTransparencyProto(finalJws.getPayload()); + assertThat(transparencyProto).isEqualTo(expectedTransparencyProto()); + } + + @Test + public void execute_injectSignature_badSignature() throws Exception { + createBundle(bundlePath); + Files.write(transparencySignatureFilePath, new byte[] {1, 1, 2, 3, 5}); + + Throwable e = + assertThrows( + CommandExecutionException.class, + () -> + AddTransparencyCommand.builder() + .setMode(Mode.INJECT_SIGNATURE) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .setTransparencySignaturePath(transparencySignatureFilePath) + .build() + .execute()); + assertThat(e) + .hasMessageThat() + .contains( + "Code transparency verification failed for the provided public key certificate and" + + " signature."); + } + + @Test + public void execute_injectSignature_weakKeySignature() throws Exception { + createBundle(bundlePath); + // create unsigned transparency file with 2048 bit length public key certificate. + createRsaKeySignerConfig(keystorePath, /* keySize= */ 2048); + String unsignedTransparencyToken = + createJwtWithoutSignature( + JsonFormat.printer() + .print( + CodeTransparencyFactory.createCodeTransparencyMetadata( + AppBundle.buildFromZip(new ZipFile(bundlePath.toFile())))), + signerConfig.getCertificates().get(0)); + byte[] unsignedTransparencyFileBytes = + CharSource.wrap(unsignedTransparencyToken).asByteSource(Charset.defaultCharset()).read(); + Files.write(outputUnsignedTransparencyFilePath, unsignedTransparencyFileBytes); + // Create signature using 2048 bit key. + Signature rsa = Signature.getInstance("SHA256withRSA"); + rsa.initSign(signerConfig.getPrivateKey()); + rsa.update(unsignedTransparencyFileBytes); + byte[] signature = rsa.sign(); + Files.write(transparencySignatureFilePath, signature); + + Throwable e = + assertThrows( + IllegalArgumentException.class, + () -> + AddTransparencyCommand.builder() + .setMode(Mode.INJECT_SIGNATURE) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .setTransparencySignaturePath(transparencySignatureFilePath) + .build() + .execute()); + assertThat(e) + .hasMessageThat() + .isEqualTo("Minimum required key length is 3072 bits, but 2048 bit key was provided."); + } + + @Test + public void execute_injectSignature_signatureKeyDoesNotMatchTransparencyFileHeader() + throws Exception { + // create bundle and unsigned transparency file. + createBundle(bundlePath); + AddTransparencyCommand.builder() + .setMode(Mode.GENERATE_CODE_TRANSPARENCY_FILE) + .setBundlePath(bundlePath) + .setOutputPath(outputUnsignedTransparencyFilePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .build() + .execute(); + // update signer config so that a new key pair is generated, which does not match the + // transparency file header, and sign the transparency file using it. + createRsaKeySignerConfig(keystorePath, /* keySize= */ 3072); + Signature rsa = Signature.getInstance("SHA256withRSA"); + rsa.initSign(signerConfig.getPrivateKey()); + rsa.update(Files.readAllBytes(outputUnsignedTransparencyFilePath)); + byte[] signature = rsa.sign(); + Files.write(transparencySignatureFilePath, signature); + + Throwable e = + assertThrows( + CommandExecutionException.class, + () -> + AddTransparencyCommand.builder() + .setMode(Mode.INJECT_SIGNATURE) + .setBundlePath(bundlePath) + .setOutputPath(outputBundlePath) + .setTransparencyKeyCertificate(signerConfig.getCertificates().get(0)) + .setTransparencySignaturePath(transparencySignatureFilePath) + .build() + .execute()); + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Code transparency verification failed for the provided public key certificate and" + + " signature."); } @Test @@ -369,13 +845,22 @@ private static void createBundle(Path path) throws Exception { } private static void createBundle(Path path, boolean hasSharedUserId) throws Exception { + createBundle(path, hasSharedUserId, /* minSdkVersion= */ 28); + } + + private static void createBundle(Path path, boolean hasSharedUserId, int minSdkVersion) + throws Exception { AppBundle appBundle = new AppBundleBuilder() - .addModule(BASE_MODULE, module -> addCodeFilesToBundleModule(module, hasSharedUserId)) .addModule( - FEATURE_MODULE1, module -> addCodeFilesToBundleModule(module, hasSharedUserId)) + BASE_MODULE, + module -> addCodeFilesToBundleModule(module, hasSharedUserId, minSdkVersion)) + .addModule( + FEATURE_MODULE1, + module -> addCodeFilesToBundleModule(module, hasSharedUserId, minSdkVersion)) .addModule( - FEATURE_MODULE2, module -> addCodeFilesToBundleModule(module, hasSharedUserId)) + FEATURE_MODULE2, + module -> addCodeFilesToBundleModule(module, hasSharedUserId, minSdkVersion)) .build(); new AppBundleSerializer().writeToDisk(appBundle, path); } @@ -417,11 +902,12 @@ private void createSignerConfig(Path keystorePath, PrivateKey privateKey, Certif } private static BundleModule addCodeFilesToBundleModule( - BundleModuleBuilder module, boolean hasSharedUserId) { + BundleModuleBuilder module, boolean hasSharedUserId, int minSdkVersion) { XmlNode manifest = hasSharedUserId - ? androidManifest("com.test.app", withSharedUserId("sharedUserId")) - : androidManifest("com.test.app"); + ? androidManifest( + "com.test.app", withSharedUserId("sharedUserId"), withMinSdkVersion(minSdkVersion)) + : androidManifest("com.test.app", withMinSdkVersion(minSdkVersion)); return module .setManifest(manifest) .addFile(DEX1, FILE_CONTENT_DEX1) @@ -482,4 +968,3 @@ private static CodeTransparency getTransparencyProto(String transparencyPayload) return transparencyProto.build(); } } - diff --git a/src/test/java/com/android/tools/build/bundletool/commands/ApkTransparencyCheckerTest.java b/src/test/java/com/android/tools/build/bundletool/commands/ApkTransparencyCheckerTest.java deleted file mode 100644 index fd368c68..00000000 --- a/src/test/java/com/android/tools/build/bundletool/commands/ApkTransparencyCheckerTest.java +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ -package com.android.tools.build.bundletool.commands; - -import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import com.android.bundle.Targeting.ApkTargeting; -import com.android.bundle.Targeting.VariantTargeting; -import com.android.tools.build.bundletool.commands.CheckTransparencyCommand.Mode; -import com.android.tools.build.bundletool.io.ApkSerializerHelper; -import com.android.tools.build.bundletool.io.ZipBuilder; -import com.android.tools.build.bundletool.model.AndroidManifest; -import com.android.tools.build.bundletool.model.BundleMetadata; -import com.android.tools.build.bundletool.model.BundleModuleName; -import com.android.tools.build.bundletool.model.ModuleEntry; -import com.android.tools.build.bundletool.model.ModuleSplit; -import com.android.tools.build.bundletool.model.ZipPath; -import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; -import com.android.tools.build.bundletool.testing.TestModule; -import com.google.common.io.ByteSource; -import com.google.protobuf.ByteString; -import dagger.Component; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; -import javax.inject.Inject; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -@RunWith(JUnit4.class) -public final class ApkTransparencyCheckerTest { - - @Rule public final TemporaryFolder tmp = new TemporaryFolder(); - - @Inject ApkSerializerHelper apkSerializerHelper; - - private Path tmpDir; - private Path zipOfApksPath; - - @Before - public void setUp() { - TestComponent.useTestModule(this, TestModule.builder().build()); - tmpDir = tmp.getRoot().toPath(); - zipOfApksPath = tmpDir.resolve("apks.zip"); - } - - @Test - public void emptyZip() throws Exception { - new ZipBuilder().writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - Throwable e = - assertThrows( - InvalidCommandException.class, - () -> ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .contains( - "The provided .zip file must either contain a single APK, or, if multiple APK files" - + " are present, a base APK."); - } - - @Test - public void noApkFoundInZip() throws Exception { - ZipBuilder zipBuilder = - new ZipBuilder() - .addFileFromDisk( - ZipPath.create("some-text-file.txt"), - Files.createFile(tmpDir.resolve("some-text-file.txt")).toFile()); - zipBuilder.writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - Throwable e = - assertThrows( - InvalidCommandException.class, - () -> ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .contains( - "The provided .zip file must either contain a single APK, or, if multiple APK files" - + " are present, a base APK."); - } - - @Test - public void singleApkInZip_noTransparencyFile() throws Exception { - Path apkPath = tmpDir.resolve("universal.apk"); - ModuleSplit split = createModuleSplit(); - apkSerializerHelper.writeToZipFile(split, apkPath); - ZipBuilder zipBuilder = - new ZipBuilder() - .addFileWithContent( - ZipPath.create("universal.apk"), - ByteString.readFrom(Files.newInputStream(apkPath)).toByteArray()); - zipBuilder.writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - Throwable e = - assertThrows( - InvalidCommandException.class, - () -> ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .contains( - "Could not verify code transparency because transparency file is not present in the" - + " APK."); - } - - @Test - public void singleApkInZip_withTransparencyFile() throws Exception { - Path apkPath = tmpDir.resolve("universal.apk"); - ModuleSplit split = - createModuleSplit( - /* transparencySignedFileContent= */ Optional.of(ByteSource.wrap(new byte[100]))); - apkSerializerHelper.writeToZipFile(split, apkPath); - ZipBuilder zipBuilder = - new ZipBuilder() - .addFileWithContent( - ZipPath.create("universal.apk"), - ByteString.readFrom(Files.newInputStream(apkPath)).toByteArray()); - zipBuilder.writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream)); - } - - @Test - public void multipleApksInZip_noBaseApk() throws Exception { - Path splitApkPath1 = tmpDir.resolve("split1.apk"); - Path splitApkPath2 = tmpDir.resolve("split2.apk"); - ModuleSplit split1 = createModuleSplit(); - ModuleSplit split2 = createModuleSplit(); - apkSerializerHelper.writeToZipFile(split1, splitApkPath1); - apkSerializerHelper.writeToZipFile(split2, splitApkPath2); - ZipBuilder zipBuilder = - new ZipBuilder() - .addFileWithContent( - ZipPath.create("split1.apk"), - ByteString.readFrom(Files.newInputStream(splitApkPath1)).toByteArray()) - .addFileWithContent( - ZipPath.create("split2.apk"), - ByteString.readFrom(Files.newInputStream(splitApkPath2)).toByteArray()); - zipBuilder.writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - Throwable e = - assertThrows( - InvalidCommandException.class, - () -> ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .contains( - "The provided .zip file must either contain a single APK, or, if multiple APK" - + " files are present, a base APK."); - } - - @Test - public void multipleApksInZip_withBaseApk_noTransparencyFile() throws Exception { - Path baseApkPath = tmpDir.resolve("base.apk"); - Path splitApkPath = tmpDir.resolve("split.apk"); - ModuleSplit base = createModuleSplit(); - ModuleSplit split = createModuleSplit(); - apkSerializerHelper.writeToZipFile(base, baseApkPath); - apkSerializerHelper.writeToZipFile(split, splitApkPath); - ZipBuilder zipBuilder = - new ZipBuilder() - .addFileWithContent( - ZipPath.create("base.apk"), - ByteString.readFrom(Files.newInputStream(baseApkPath)).toByteArray()) - .addFileWithContent( - ZipPath.create("split.apk"), - ByteString.readFrom(Files.newInputStream(splitApkPath)).toByteArray()); - zipBuilder.writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - Throwable e = - assertThrows( - InvalidCommandException.class, - () -> ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .contains( - "Could not verify code transparency because transparency file is not present in the" - + " APK."); - } - - @Test - public void multipleApksInZip_withBaseApk_withTransparencyFile() throws Exception { - Path baseApkPath = tmpDir.resolve("base.apk"); - Path splitApkPath = tmpDir.resolve("split.apk"); - ModuleSplit base = - createModuleSplit( - /* transparencySignedFileContent= */ Optional.of(ByteSource.wrap(new byte[100]))); - ModuleSplit split = createModuleSplit(); - apkSerializerHelper.writeToZipFile(base, baseApkPath); - apkSerializerHelper.writeToZipFile(split, splitApkPath); - ZipBuilder zipBuilder = - new ZipBuilder() - .addFileWithContent( - ZipPath.create("base.apk"), - ByteString.readFrom(Files.newInputStream(baseApkPath)).toByteArray()) - .addFileWithContent( - ZipPath.create("split.apk"), - ByteString.readFrom(Files.newInputStream(splitApkPath)).toByteArray()); - zipBuilder.writeTo(zipOfApksPath); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - CheckTransparencyCommand command = - CheckTransparencyCommand.builder().setMode(Mode.APK).setApkZipPath(zipOfApksPath).build(); - - ApkTransparencyChecker.checkTransparency(command, new PrintStream(outputStream)); - } - - private static ModuleSplit createModuleSplit() { - return createModuleSplit(/* transparencySignedFileContent= */ Optional.empty()); - } - - private static ModuleSplit createModuleSplit(Optional transparencySignedFileContent) { - ModuleSplit.Builder moduleSplitBuilder = - ModuleSplit.builder() - .setModuleName(BundleModuleName.create("base")) - .setAndroidManifest(AndroidManifest.create(androidManifest("com.app"))) - .setApkTargeting(ApkTargeting.getDefaultInstance()) - .setVariantTargeting(VariantTargeting.getDefaultInstance()) - .setMasterSplit(true); - - transparencySignedFileContent.ifPresent( - transparencyFile -> - moduleSplitBuilder.addEntry( - ModuleEntry.builder() - .setContent(transparencyFile) - .setPath( - ZipPath.create("META-INF") - .resolve(BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME)) - .build())); - - return moduleSplitBuilder.build(); - } - - @CommandScoped - @Component(modules = {BuildApksModule.class, TestModule.class}) - interface TestComponent { - - void inject(ApkTransparencyCheckerTest test); - - static void useTestModule(ApkTransparencyCheckerTest testInstance, TestModule testModule) { - DaggerApkTransparencyCheckerTest_TestComponent.builder() - .testModule(testModule) - .build() - .inject(testInstance); - } - } -} diff --git a/src/test/java/com/android/tools/build/bundletool/commands/BuildApksCommandTest.java b/src/test/java/com/android/tools/build/bundletool/commands/BuildApksCommandTest.java index 2a17fd07..97177ac8 100644 --- a/src/test/java/com/android/tools/build/bundletool/commands/BuildApksCommandTest.java +++ b/src/test/java/com/android/tools/build/bundletool/commands/BuildApksCommandTest.java @@ -1338,8 +1338,8 @@ public void badTransparencyFile_throws() throws Exception { assertThat(e) .hasMessageThat() .contains( - "Code transparency verification failed because code was modified after transparency" - + " metadata generation."); + "Verification failed because code was modified after transparency metadata" + + " generation."); } diff --git a/src/test/java/com/android/tools/build/bundletool/commands/BuildApksManagerTest.java b/src/test/java/com/android/tools/build/bundletool/commands/BuildApksManagerTest.java index d8b4df99..7787762c 100644 --- a/src/test/java/com/android/tools/build/bundletool/commands/BuildApksManagerTest.java +++ b/src/test/java/com/android/tools/build/bundletool/commands/BuildApksManagerTest.java @@ -47,6 +47,7 @@ import static com.android.tools.build.bundletool.testing.ApkSetUtils.extractFromApkSetFile; import static com.android.tools.build.bundletool.testing.ApkSetUtils.extractTocFromApkSetFile; import static com.android.tools.build.bundletool.testing.ApkSetUtils.parseTocFromFile; +import static com.android.tools.build.bundletool.testing.CodeTransparencyTestUtils.createJwsToken; import static com.android.tools.build.bundletool.testing.DeviceFactory.abis; import static com.android.tools.build.bundletool.testing.DeviceFactory.density; import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceTier; @@ -59,7 +60,7 @@ import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withAppIcon; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withCustomThemeActivity; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withDelivery; -import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withDeviceTiersCondition; +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withDeviceGroupsCondition; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFusingAttribute; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withInstallLocation; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withInstallTimeDelivery; @@ -91,7 +92,7 @@ import static com.android.tools.build.bundletool.testing.TargetingUtils.deviceTierTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.languageTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.mergeVariantTargeting; -import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceTiersTargeting; +import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceGroupsTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.nativeDirectoryTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.nativeLibraries; import static com.android.tools.build.bundletool.testing.TargetingUtils.sdkVersionFrom; @@ -125,6 +126,8 @@ import com.android.aapt.Resources.XmlNode; import com.android.apex.ApexManifestProto.ApexManifest; import com.android.apksig.ApkVerifier; +import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; +import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; import com.android.bundle.Commands.ApkDescription; import com.android.bundle.Commands.ApkSet; import com.android.bundle.Commands.AssetModulesInfo; @@ -166,6 +169,7 @@ import com.android.tools.build.bundletool.model.AndroidManifest; import com.android.tools.build.bundletool.model.ApkModifier; import com.android.tools.build.bundletool.model.AppBundle; +import com.android.tools.build.bundletool.model.BundleMetadata; import com.android.tools.build.bundletool.model.SigningConfiguration; import com.android.tools.build.bundletool.model.SourceStamp; import com.android.tools.build.bundletool.model.ZipPath; @@ -190,7 +194,10 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Maps; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteSource; import com.google.common.io.ByteStreams; +import com.google.common.io.CharSource; import com.google.common.io.Closer; import com.google.common.truth.Correspondence; import com.google.common.util.concurrent.ListeningExecutorService; @@ -202,6 +209,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -283,7 +291,9 @@ public class BuildApksManagerTest { public static void setUpClass() throws Exception { // Creating a new key takes in average 75ms (with peaks at 200ms), so creating a single one for // all the tests. - KeyPair keyPair = KeyPairGenerator.getInstance("RSA").genKeyPair(); + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(/* keySize= */ 3072); + KeyPair keyPair = kpg.genKeyPair(); privateKey = keyPair.getPrivate(); certificate = CertificateFactory.buildSelfSignedCertificate(keyPair, "CN=BuildApksCommandTest"); } @@ -1626,7 +1636,6 @@ public void buildApksCommand_system_generatesSingleApkWithEmptyOptimizations() t TruthZip.assertThat(systemApkZipFile) .containsExactlyEntries( "AndroidManifest.xml", - "META-INF/MANIFEST.MF", "lib/x86/libsome.so", "lib/x86_64/libsome.so", "res/drawable-ldpi/image.jpg", @@ -3711,7 +3720,6 @@ private void runSingleConcurrencyTest_disableNativeLibrariesOptimization(int thr BuildApksResult result = extractTocFromApkSetFile(apkSetFile, outputDir); final String manifest = "AndroidManifest.xml"; - final String signature = "META-INF/MANIFEST.MF"; // Validate split APKs. ImmutableList splitApkVariants = splitApkVariants(result); @@ -3736,19 +3744,13 @@ private void runSingleConcurrencyTest_disableNativeLibrariesOptimization(int thr // Correct files inside APKs. assertThat(filesInApks(splitApksByModule.get("base"), apkSetFile)) .containsExactly( - "res/xml/splits0.xml", - "resources.arsc", - "assets/file.txt", - "classes.dex", - manifest, - signature); + "res/xml/splits0.xml", "resources.arsc", "assets/file.txt", "classes.dex", manifest); assertThat(filesInApks(splitApksByModule.get("abi_feature"), apkSetFile)) .isEqualTo( ImmutableMultiset.builder() .add("lib/x86/libsome.so") .add("lib/x86_64/libsome.so") .addCopies(manifest, 3) - .addCopies(signature, 3) .build()); assertThat(filesInApks(splitApksByModule.get("language_feature"), apkSetFile)) .isEqualTo( @@ -3759,7 +3761,6 @@ private void runSingleConcurrencyTest_disableNativeLibrariesOptimization(int thr .add("res/drawable-pl/image.jpg") .addCopies("resources.arsc", 4) .addCopies(manifest, 4) - .addCopies(signature, 4) .build()); // Validate standalone APKs. @@ -3785,7 +3786,6 @@ private void runSingleConcurrencyTest_disableNativeLibrariesOptimization(int thr // "res/xml/splits0.xml" is created by bundletool with list of generated splits. .addCopies("resources.arsc", 2) .addCopies(manifest, 2) - .addCopies(signature, 2) .build()); } @@ -4674,7 +4674,7 @@ public void deviceTieredAssets_withDeviceSpec_deviceTierNotSet_defaultIsUsed() t } @Test - public void deviceTieredConditionalModule() throws Exception { + public void deviceGroupTargetedConditionalModule() throws Exception { AppBundle appBundle = new AppBundleBuilder() .addModule( @@ -4691,7 +4691,7 @@ public void deviceTieredConditionalModule() throws Exception { "com.test", withTitle("@string/test_label", TEST_LABEL_RESOURCE_ID), withFusingAttribute(false), - withDeviceTiersCondition(ImmutableList.of("medium", "high"))))) + withDeviceGroupsCondition(ImmutableList.of("group1", "group2"))))) .build(); TestComponent.useTestModule( @@ -4710,7 +4710,7 @@ public void deviceTieredConditionalModule() throws Exception { .distinct() .collect(onlyElement()); assertThat(deviceTierModule.getTargeting()) - .isEqualTo(moduleDeviceTiersTargeting("medium", "high")); + .isEqualTo(moduleDeviceGroupsTargeting("group1", "group2")); } @Test @@ -5114,8 +5114,15 @@ public void pinningOfManifestReachableResources_enabledSince_0_8_1() throws Exce } } + @DataPoints("manifestReachableResourcesDisabledVersions") + public static final ImmutableSet MANIFEST_REACHABLE_RESOURCES_DISABLED_VERSIONS = + ImmutableSet.of(Version.of("0.8.0"), Version.of("1.7.0")); + @Test - public void pinningOfManifestReachableResources_disabledBefore_0_8_1() throws Exception { + @Theory + public void pinningOfManifestReachableResources_disabled( + @FromDataPoints("manifestReachableResourcesDisabledVersions") Version bundleVersion) + throws Exception { AppBundle appBundle = new AppBundleBuilder() .addModule( @@ -5137,7 +5144,8 @@ public void pinningOfManifestReachableResources_disabledBefore_0_8_1() throws Ex /* mdpi */ 160, "res/drawable-mdpi/manifest_image.jpg", /* hdpi */ 240, "res/drawable-hdpi/manifest_image.jpg")) .build())) - .setBundleConfig(BundleConfigBuilder.create().setVersion("0.8.0").build()) + .setBundleConfig( + BundleConfigBuilder.create().setVersion(bundleVersion.toString()).build()) .build(); TestComponent.useTestModule( this, TestModule.builder().withAppBundle(appBundle).withOutputPath(outputFilePath).build()); @@ -5407,6 +5415,136 @@ public void packageNameIsPropagatedToBuildResult() throws Exception { assertThat(result.getPackageName()).isEqualTo("com.app"); } + @Test + public void transparencyFilePropagatedAsExpected() throws Exception { + String dexFilePath = "dex/classes.dex"; + byte[] dexFileInBaseModuleContent = TestData.readBytes("testdata/dex/classes.dex"); + byte[] dexFileInFeatureModuleContent = TestData.readBytes("testdata/dex/classes-other.dex"); + String libFilePath = "lib/x86_64/libsome.so"; + byte[] libFileInBaseModuleContent = new byte[] {4, 5, 6}; + CodeTransparency codeTransparency = + CodeTransparency.newBuilder() + .addCodeRelatedFile( + CodeRelatedFile.newBuilder() + .setType(CodeRelatedFile.Type.DEX) + .setPath("base/" + dexFilePath) + .setSha256( + ByteSource.wrap(dexFileInBaseModuleContent) + .hash(Hashing.sha256()) + .toString())) + .addCodeRelatedFile( + CodeRelatedFile.newBuilder() + .setType(CodeRelatedFile.Type.NATIVE_LIBRARY) + .setPath("base/" + libFilePath) + .setSha256( + ByteSource.wrap(libFileInBaseModuleContent) + .hash(Hashing.sha256()) + .toString()) + .setApkPath(libFilePath)) + .addCodeRelatedFile( + CodeRelatedFile.newBuilder() + .setType(CodeRelatedFile.Type.DEX) + .setPath("feature/" + dexFilePath) + .setSha256( + ByteSource.wrap(dexFileInFeatureModuleContent) + .hash(Hashing.sha256()) + .toString())) + .build(); + Path bundlePath = tmpDir.resolve("bundle.aab"); + AppBundleBuilder appBundle = + new AppBundleBuilder() + .addModule( + "base", + module -> + module + .setManifest(androidManifest("com.test.app", withMinSdkVersion(20))) + .setResourceTable(resourceTableWithTestLabel("Test feature")) + .addFile( + dexFilePath, + bundlePath, + ZipPath.create("base/" + dexFilePath), + dexFileInBaseModuleContent) + .addFile( + libFilePath, + bundlePath, + ZipPath.create("base/" + libFilePath), + libFileInBaseModuleContent) + .setNativeConfig( + nativeLibraries( + targetedNativeDirectory( + "lib/x86_64", nativeDirectoryTargeting(AbiAlias.X86_64))))) + .addModule( + "feature", + module -> + module + .setManifest( + androidManifest( + "com.test.app", + withDelivery(DeliveryType.ON_DEMAND), + withFusingAttribute(true), + withTitle("@string/test_label", TEST_LABEL_RESOURCE_ID))) + .addFile( + dexFilePath, + bundlePath, + ZipPath.create("feature/" + dexFilePath), + dexFileInFeatureModuleContent)) + .addMetadataFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + CharSource.wrap(createJwsToken(codeTransparency, certificate, privateKey)) + .asByteSource(Charset.defaultCharset())); + + TestComponent.useTestModule( + this, + TestModule.builder() + .withCustomBuildApksCommandSetter(command -> command.setEnableNewApkSerializer(true)) + .withOutputPath(outputFilePath) + .withAppBundle(appBundle.build()) + .build()); + buildApksManager.execute(); + + ZipFile apkSetFile = openZipFile(outputFilePath.toFile()); + BuildApksResult result = extractTocFromApkSetFile(apkSetFile, outputDir); + + ImmutableList splitApks = apkDescriptions(splitApkVariants(result)); + + // Transparency file should be propagated to main split of the base module. + ImmutableList mainSplitsOfBaseModule = + splitApks.stream() + .filter( + apk -> + apk.getSplitApkMetadata().getSplitId().isEmpty() + && apk.getSplitApkMetadata().getIsMasterSplit()) + .collect(toImmutableList()); + assertThat(mainSplitsOfBaseModule).hasSize(2); + for (ApkDescription apk : mainSplitsOfBaseModule) { + ZipFile zipFile = openZipFile(extractFromApkSetFile(apkSetFile, apk.getPath(), outputDir)); + assertThat(filesUnderPath(zipFile, ZipPath.create("META-INF"))) + .contains("META-INF/" + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME); + } + + // Other splits should not contain transparency file. + ImmutableList otherSplits = + splitApks.stream() + .filter(apk -> !apk.getSplitApkMetadata().getSplitId().isEmpty()) + .collect(toImmutableList()); + assertThat(otherSplits).hasSize(4); + for (ApkDescription apk : otherSplits) { + ZipFile zipFile = openZipFile(extractFromApkSetFile(apkSetFile, apk.getPath(), outputDir)); + assertThat(filesUnderPath(zipFile, ZipPath.create("META-INF"))).isEmpty(); + } + + // Because minSdkVersion < 21, bundle has a feature module and merging strategy is + // MERGE_IF_NEEDED (default), transparency file should not be propagated to standalone APK. + assertThat(standaloneApkVariants(result)).hasSize(1); + ImmutableList standaloneApks = + apkDescriptions(standaloneApkVariants(result).get(0)); + File standaloneApkFile = + extractFromApkSetFile(apkSetFile, standaloneApks.get(0).getPath(), outputDir); + ZipFile standaloneApkZip = openZipFile(standaloneApkFile); + assertThat(filesUnderPath(standaloneApkZip, ZipPath.create("META-INF"))).isEmpty(); + } + private static ImmutableList apkDescriptions(List variants) { return variants.stream() .flatMap(variant -> apkDescriptions(variant).stream()) diff --git a/src/test/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommandTest.java b/src/test/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommandTest.java index d4704f9b..4d23bac4 100644 --- a/src/test/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommandTest.java +++ b/src/test/java/com/android/tools/build/bundletool/commands/CheckTransparencyCommandTest.java @@ -15,53 +15,63 @@ */ package com.android.tools.build.bundletool.commands; +import static com.android.tools.build.bundletool.testing.CodeTransparencyTestUtils.createJwsToken; import static com.android.tools.build.bundletool.testing.FakeSystemEnvironmentProvider.ANDROID_HOME; import static com.android.tools.build.bundletool.testing.FakeSystemEnvironmentProvider.ANDROID_SERIAL; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; -import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withSharedUserId; import static com.google.common.truth.Truth.assertThat; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.stream.Collectors.toMap; -import static org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256; import static org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA384; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; -import com.android.aapt.Resources.XmlNode; import com.android.bundle.CodeTransparencyOuterClass.CodeRelatedFile; import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; +import com.android.bundle.Targeting.ApkTargeting; +import com.android.bundle.Targeting.VariantTargeting; import com.android.tools.build.bundletool.commands.CheckTransparencyCommand.Mode; import com.android.tools.build.bundletool.device.AdbServer; import com.android.tools.build.bundletool.flags.Flag.RequiredFlagNotSetException; import com.android.tools.build.bundletool.flags.FlagParser; import com.android.tools.build.bundletool.flags.ParsedFlags.UnknownFlagsException; +import com.android.tools.build.bundletool.io.ApkSerializerHelper; import com.android.tools.build.bundletool.io.AppBundleSerializer; +import com.android.tools.build.bundletool.io.ZipBuilder; +import com.android.tools.build.bundletool.model.AndroidManifest; import com.android.tools.build.bundletool.model.BundleMetadata; -import com.android.tools.build.bundletool.model.BundleModule; -import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; +import com.android.tools.build.bundletool.model.BundleModuleName; +import com.android.tools.build.bundletool.model.ModuleEntry; +import com.android.tools.build.bundletool.model.ModuleSplit; +import com.android.tools.build.bundletool.model.SignerConfig; +import com.android.tools.build.bundletool.model.SigningConfiguration; +import com.android.tools.build.bundletool.model.ZipPath; +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; +import com.android.tools.build.bundletool.model.exceptions.InvalidCommandException; import com.android.tools.build.bundletool.model.utils.SystemEnvironmentProvider; import com.android.tools.build.bundletool.testing.AppBundleBuilder; -import com.android.tools.build.bundletool.testing.BundleModuleBuilder; import com.android.tools.build.bundletool.testing.CertificateFactory; import com.android.tools.build.bundletool.testing.FakeSystemEnvironmentProvider; +import com.android.tools.build.bundletool.testing.TestModule; +import com.android.tools.build.bundletool.transparency.CodeTransparencyCryptoUtils; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.hash.Hashing; import com.google.common.io.ByteSource; import com.google.common.io.CharSource; -import com.google.protobuf.util.JsonFormat; +import com.google.protobuf.ByteString; +import dagger.Component; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.cert.X509Certificate; -import java.util.Map; -import java.util.Optional; +import javax.inject.Inject; import org.jose4j.jws.JsonWebSignature; -import org.jose4j.lang.JoseException; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -72,26 +82,24 @@ @RunWith(JUnit4.class) public final class CheckTransparencyCommandTest { - private static final String BASE_MODULE = "base"; - private static final String FEATURE_MODULE1 = "feature1"; - private static final String FEATURE_MODULE2 = "feature2"; - private static final String DEX_PATH1 = "dex/classes.dex"; - private static final String DEX_PATH2 = "dex/classes2.dex"; - private static final String NATIVE_LIB_PATH1 = "lib/arm64-v8a/libnative.so"; - private static final String NATIVE_LIB_PATH2 = "lib/armeabi-v7a/libnative.so"; - private static final byte[] FILE_CONTENT = new byte[] {1, 2, 3}; private static final Path ADB_PATH = Paths.get("third_party/java/android/android_sdk_linux/platform-tools/adb.static"); private static final String DEVICE_ID = "id1"; + private static final String PACKAGE_NAME = "com.test.app"; @Rule public final TemporaryFolder tmp = new TemporaryFolder(); + @Inject ApkSerializerHelper apkSerializerHelper; + private Path tmpDir; private Path bundlePath; private Path apkZipPath; private KeyPairGenerator kpg; - private PrivateKey privateKey; - private X509Certificate certificate; + private PrivateKey transparencyPrivateKey; + private X509Certificate transparencyKeyCertificate; + private X509Certificate apkSigningKeyCertificate; + private Path transparencyKeyCertificatePath; + private Path apkSigningKeyCertificatePath; private final AdbServer fakeAdbServer = mock(AdbServer.class); private final SystemEnvironmentProvider systemEnvironmentProvider = @@ -101,14 +109,41 @@ public final class CheckTransparencyCommandTest { @Before public void setUp() throws Exception { tmpDir = tmp.getRoot().toPath(); - bundlePath = tmpDir.resolve("bundle.aab"); - apkZipPath = tmpDir.resolve("apks.zip"); kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(/* keySize= */ 3072); + KeyPair keyPair = kpg.genKeyPair(); - privateKey = keyPair.getPrivate(); - certificate = - CertificateFactory.buildSelfSignedCertificate(keyPair, "CN=CheckTransparencyCommandTest"); + transparencyPrivateKey = keyPair.getPrivate(); + transparencyKeyCertificate = + CertificateFactory.buildSelfSignedCertificate( + keyPair, "CN=CheckTransparencyCommandTest_TransparencyKey"); + transparencyKeyCertificatePath = tmpDir.resolve("transparency-public.cert"); + Files.write(transparencyKeyCertificatePath, transparencyKeyCertificate.getEncoded()); + + KeyPair apkSigningKeyPair = kpg.genKeyPair(); + apkSigningKeyCertificate = + CertificateFactory.buildSelfSignedCertificate( + apkSigningKeyPair, "CN=CheckTransparencyCommandTest_ApkSigningKey"); + apkSigningKeyCertificatePath = tmpDir.resolve("apk-signing-key-public.cert"); + Files.write(apkSigningKeyCertificatePath, apkSigningKeyCertificate.getEncoded()); + SigningConfiguration apkSigningConfig = + SigningConfiguration.builder() + .setSignerConfig( + SignerConfig.builder() + .setPrivateKey(apkSigningKeyPair.getPrivate()) + .setCertificates(ImmutableList.of(apkSigningKeyCertificate)) + .build()) + .build(); + + TestComponent.useTestModule( + this, + TestModule.builder() + .withCustomBuildApksCommandSetter(command -> command.setEnableNewApkSerializer(true)) + .withSigningConfig(apkSigningConfig) + .build()); + + bundlePath = tmpDir.resolve("bundle.aab"); + apkZipPath = tmpDir.resolve("apks.zip"); } @Test @@ -176,6 +211,63 @@ public void buildingCommandViaFlagsAndBuilderHasSameResult_bundleMode() { assertThat(commandViaBuilder).isEqualTo(commandViaFlags); } + @Test + public void buildingCommandViaFlags_bundleMode_withTransparencyCertificateFlag() { + CheckTransparencyCommand commandViaFlags = + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=BUNDLE", + "--bundle=" + bundlePath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath), + systemEnvironmentProvider, + fakeAdbServer); + CheckTransparencyCommand commandViaBuilder = + CheckTransparencyCommand.builder() + .setMode(Mode.BUNDLE) + .setBundlePath(bundlePath) + .setTransparencyKeyCertificate(transparencyKeyCertificate) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void buildingCommandViaFlags_bundleMode_withTransparencyCertificateFlag_invalidFormat() { + Throwable e = + assertThrows( + InvalidCommandException.class, + () -> + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=BUNDLE", + "--bundle=" + bundlePath, + "--transparency-key-certificate=" + apkZipPath), + systemEnvironmentProvider, + fakeAdbServer)); + assertThat(e) + .hasMessageThat() + .contains("Unable to read public key certificate from the provided path."); + } + + @Test + public void buildingCommandViaFlags_bundleMode_withApkSigningKeyCertificateFlag() { + Throwable e = + assertThrows( + UnknownFlagsException.class, + () -> + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=BUNDLE", + "--bundle=" + bundlePath, + "--apk-signing-key-certificate=" + apkSigningKeyCertificatePath), + systemEnvironmentProvider, + fakeAdbServer)); + assertThat(e).hasMessageThat().contains("Unrecognized flags: --apk-signing-key-certificate"); + } + @Test public void buildingCommandViaFlags_apkMode_apkZipPathNotSet() { Throwable e = @@ -230,6 +322,48 @@ public void buildingCommandViaFlagsAndBuilderHasSameResult_apkMode() { assertThat(commandViaBuilder).isEqualTo(commandViaFlags); } + @Test + public void buildingCommandViaFlags_apkMode_withTransparencyCertificateFlag() { + CheckTransparencyCommand commandViaFlags = + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=APK", + "--apk-zip=" + apkZipPath, + "--transparency-key-certificate=" + transparencyKeyCertificatePath), + systemEnvironmentProvider, + fakeAdbServer); + CheckTransparencyCommand commandViaBuilder = + CheckTransparencyCommand.builder() + .setMode(Mode.APK) + .setApkZipPath(apkZipPath) + .setTransparencyKeyCertificate(transparencyKeyCertificate) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void buildingCommandViaFlags_apkMode_withApkSigningKeyCertificateFlag() { + CheckTransparencyCommand commandViaFlags = + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=APK", + "--apk-zip=" + apkZipPath, + "--apk-signing-key-certificate=" + apkSigningKeyCertificatePath), + systemEnvironmentProvider, + fakeAdbServer); + CheckTransparencyCommand commandViaBuilder = + CheckTransparencyCommand.builder() + .setMode(Mode.APK) + .setApkZipPath(apkZipPath) + .setApkSigningKeyCertificate(apkSigningKeyCertificate) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + @Test public void buildingCommandViaFlags_connectedDeviceMode_bundleFlagSet() { Throwable e = @@ -241,6 +375,7 @@ public void buildingCommandViaFlags_connectedDeviceMode_bundleFlagSet() { .parse( "--mode=CONNECTED_DEVICE", "--adb=" + ADB_PATH, + "--package-name=" + PACKAGE_NAME, "--bundle=" + bundlePath), systemEnvironmentProvider, fakeAdbServer)); @@ -259,6 +394,7 @@ public void buildingCommandViaFlags_connectedDeviceMode_unknownFlagSet() { "--mode=CONNECTED_DEVICE", "--connected-device=true", "--adb=" + ADB_PATH, + "--package-name=" + PACKAGE_NAME, "--unknownFlag=hello"), systemEnvironmentProvider, fakeAdbServer)); @@ -270,7 +406,62 @@ public void buildingCommandViaFlagsAndBuilderHasSameResult_connectedDeviceMode() CheckTransparencyCommand commandViaFlags = CheckTransparencyCommand.fromFlags( new FlagParser() - .parse("--mode=CONNECTED_DEVICE", "--adb=" + ADB_PATH, "--device-id=" + DEVICE_ID), + .parse( + "--mode=CONNECTED_DEVICE", + "--adb=" + ADB_PATH, + "--device-id=" + DEVICE_ID, + "--package-name=" + PACKAGE_NAME), + systemEnvironmentProvider, + fakeAdbServer); + CheckTransparencyCommand commandViaBuilder = + CheckTransparencyCommand.builder() + .setMode(Mode.CONNECTED_DEVICE) + .setAdbPath(ADB_PATH) + .setAdbServer(fakeAdbServer) + .setDeviceId(DEVICE_ID) + .setPackageName(PACKAGE_NAME) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void buildingCommandViaFlags_connectedDeviceMode_withTransparencyCertificateFlag() { + CheckTransparencyCommand commandViaFlags = + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=CONNECTED_DEVICE", + "--adb=" + ADB_PATH, + "--device-id=" + DEVICE_ID, + "--package-name=" + PACKAGE_NAME, + "--transparency-key-certificate=" + transparencyKeyCertificatePath), + systemEnvironmentProvider, + fakeAdbServer); + CheckTransparencyCommand commandViaBuilder = + CheckTransparencyCommand.builder() + .setMode(Mode.CONNECTED_DEVICE) + .setAdbPath(ADB_PATH) + .setAdbServer(fakeAdbServer) + .setDeviceId(DEVICE_ID) + .setPackageName(PACKAGE_NAME) + .setTransparencyKeyCertificate(transparencyKeyCertificate) + .build(); + + assertThat(commandViaBuilder).isEqualTo(commandViaFlags); + } + + @Test + public void buildingCommandViaFlags_connectedDeviceMode_withApkSigningKeyCertificateFlag() { + CheckTransparencyCommand commandViaFlags = + CheckTransparencyCommand.fromFlags( + new FlagParser() + .parse( + "--mode=CONNECTED_DEVICE", + "--adb=" + ADB_PATH, + "--device-id=" + DEVICE_ID, + "--package-name=" + PACKAGE_NAME, + "--apk-signing-key-certificate=" + apkSigningKeyCertificatePath), systemEnvironmentProvider, fakeAdbServer); CheckTransparencyCommand commandViaBuilder = @@ -279,16 +470,22 @@ public void buildingCommandViaFlagsAndBuilderHasSameResult_connectedDeviceMode() .setAdbPath(ADB_PATH) .setAdbServer(fakeAdbServer) .setDeviceId(DEVICE_ID) + .setPackageName(PACKAGE_NAME) + .setApkSigningKeyCertificate(apkSigningKeyCertificate) .build(); assertThat(commandViaBuilder).isEqualTo(commandViaFlags); } @Test - public void buildingCommandViaFlags_connectedDeviceMode_deviceIdRetrievedFromEnvironmend() { + public void buildingCommandViaFlags_connectedDeviceMode_deviceIdRetrievedFromEnvironment() { CheckTransparencyCommand commandViaFlags = CheckTransparencyCommand.fromFlags( - new FlagParser().parse("--mode=CONNECTED_DEVICE", "--adb=" + ADB_PATH), + new FlagParser() + .parse( + "--mode=CONNECTED_DEVICE", + "--adb=" + ADB_PATH, + "--package-name=" + PACKAGE_NAME), systemEnvironmentProvider, fakeAdbServer); CheckTransparencyCommand commandViaBuilder = @@ -297,6 +494,7 @@ public void buildingCommandViaFlags_connectedDeviceMode_deviceIdRetrievedFromEnv .setAdbPath(ADB_PATH) .setAdbServer(fakeAdbServer) .setDeviceId(DEVICE_ID) + .setPackageName(PACKAGE_NAME) .build(); assertThat(commandViaBuilder).isEqualTo(commandViaFlags); @@ -345,94 +543,138 @@ public void execute_apkMode_wrongInputFileFormat() { } @Test - public void execute_bundletMode_transparencyFileMissing() throws Exception { - createBundle(bundlePath, /* transparencyMetadata= */ Optional.empty()); - CheckTransparencyCommand checkTransparencyCommand = - CheckTransparencyCommand.builder().setMode(Mode.BUNDLE).setBundlePath(bundlePath).build(); + public void bundleMode_unsupportedSignatureAlgorithm() throws Exception { + String serializedJws = + createJwsToken( + CodeTransparency.getDefaultInstance(), + transparencyKeyCertificate, + transparencyPrivateKey, + RSA_USING_SHA384); + AppBundleBuilder appBundle = + new AppBundleBuilder() + .addModule("base", module -> module.setManifest(androidManifest("com.test.app"))) + .addMetadataFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())); + new AppBundleSerializer().writeToDisk(appBundle.build(), bundlePath); - Throwable e = assertThrows(InvalidBundleException.class, checkTransparencyCommand::execute); + Throwable e = + assertThrows( + CommandExecutionException.class, + () -> + CheckTransparencyCommand.builder() + .setMode(Mode.BUNDLE) + .setBundlePath(bundlePath) + .build() + .checkTransparency(new PrintStream(new ByteArrayOutputStream()))); assertThat(e) .hasMessageThat() - .isEqualTo( - "Bundle does not include code transparency metadata. Run `add-transparency` command to" - + " add code transparency metadata to the bundle."); - } - - @Test - public void execute_bundleMode_codeModified() throws Exception { - CodeTransparency.Builder codeTransparency = - createValidTransparencyProto( - ByteSource.wrap(FILE_CONTENT).hash(Hashing.sha256()).toString()) - .toBuilder(); - Map codeRelatedFileMap = - codeTransparency.getCodeRelatedFileList().stream() - .collect(toMap(CodeRelatedFile::getPath, codeRelatedFile -> codeRelatedFile)); - codeRelatedFileMap.put( - "dex/deleted.dex", - CodeRelatedFile.newBuilder() - .setType(CodeRelatedFile.Type.DEX) - .setPath("dex/deleted.dex") - .build()); - codeRelatedFileMap.remove(BASE_MODULE + "/" + DEX_PATH1); - codeRelatedFileMap.put( - BASE_MODULE + "/" + DEX_PATH2, - CodeRelatedFile.newBuilder() - .setType(CodeRelatedFile.Type.DEX) - .setPath(BASE_MODULE + "/" + DEX_PATH2) - .setSha256("modifiedSHa256") - .build()); - codeTransparency.clearCodeRelatedFile().addAllCodeRelatedFile(codeRelatedFileMap.values()); - createBundle(bundlePath, Optional.of(codeTransparency.build())); + .contains("Exception while verifying code transparency signature."); + } + + @Test + public void bundleMode_transparencyVerified_transparencyKeyCertificateProvidedByUser() + throws Exception { + String serializedJws = + createJwsToken( + CodeTransparency.getDefaultInstance(), + transparencyKeyCertificate, + transparencyPrivateKey); + AppBundleBuilder appBundle = + new AppBundleBuilder() + .addModule("base", module -> module.setManifest(androidManifest("com.test.app"))) + .addMetadataFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())); + new AppBundleSerializer().writeToDisk(appBundle.build(), bundlePath); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); CheckTransparencyCommand.builder() .setMode(Mode.BUNDLE) .setBundlePath(bundlePath) + .setTransparencyKeyCertificate(transparencyKeyCertificate) .build() .checkTransparency(new PrintStream(outputStream)); String output = new String(outputStream.toByteArray(), UTF_8); + assertThat(output).contains("No APK present. APK signature was not checked."); assertThat(output) .contains( - "Code transparency verification failed because code was modified after transparency" - + " metadata generation."); - assertThat(output) - .contains("Files deleted after transparency metadata generation: [dex/deleted.dex]"); - assertThat(output) - .contains("Files added after transparency metadata generation: [base/dex/classes.dex]"); + "Code transparency signature verified for the provided code transparency key" + + " certificate."); assertThat(output) - .contains("Files modified after transparency metadata generation: [base/dex/classes2.dex]"); + .contains( + "Code transparency verified: code related file contents match the code transparency" + + " file."); } @Test - public void execute_transparencyVerified_invalidSignature() throws Exception { - // Update certificate value so that it does not match the private key that will used to sign the - // transparency metadata. - certificate = - CertificateFactory.buildSelfSignedCertificate( - kpg.generateKeyPair(), "CN=CodeTransparencyValidatorTest_WrongCertificate"); - CodeTransparency validCodeTransparency = - createValidTransparencyProto( - ByteSource.wrap(FILE_CONTENT).hash(Hashing.sha256()).toString()); - createBundle(bundlePath, Optional.of(validCodeTransparency)); + public void bundleMode_verificationFailed_badCertificateProvidedByUser() throws Exception { + String serializedJws = + createJwsToken( + CodeTransparency.getDefaultInstance(), + transparencyKeyCertificate, + transparencyPrivateKey); + AppBundleBuilder appBundle = + new AppBundleBuilder() + .addModule("base", module -> module.setManifest(androidManifest("com.test.app"))) + .addMetadataFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())); + new AppBundleSerializer().writeToDisk(appBundle.build(), bundlePath); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + X509Certificate badCertificate = + CertificateFactory.buildSelfSignedCertificate( + kpg.generateKeyPair(), "CN=CheckTransparencyCommandTest_BadCertificate"); CheckTransparencyCommand.builder() - .setBundlePath(bundlePath) .setMode(Mode.BUNDLE) + .setBundlePath(bundlePath) + .setTransparencyKeyCertificate(badCertificate) .build() .checkTransparency(new PrintStream(outputStream)); - assertThat(new String(outputStream.toByteArray(), UTF_8)) - .contains("Code transparency verification failed because signature is invalid."); + String output = new String(outputStream.toByteArray(), UTF_8); + assertThat(output).contains("No APK present. APK signature was not checked."); + assertThat(output) + .contains( + "Code transparency verification failed because the provided public key certificate does" + + " not match the code transparency file."); + assertThat(output) + .contains( + "SHA-256 fingerprint of the certificate that was used to sign code" + + " transparency file: " + + CodeTransparencyCryptoUtils.getCertificateFingerprint( + transparencyKeyCertificate)); + assertThat(output) + .contains( + "SHA-256 fingerprint of the certificate that was provided: " + + CodeTransparencyCryptoUtils.getCertificateFingerprint(badCertificate)); } @Test - public void execute_bundleMode_transparencyVerified() throws Exception { - CodeTransparency validCodeTransparency = - createValidTransparencyProto( - ByteSource.wrap(FILE_CONTENT).hash(Hashing.sha256()).toString()); - createBundle(bundlePath, Optional.of(validCodeTransparency)); + public void bundleMode_verificationFailed_transparencyKeyCertificateNotProvidedByUser() + throws Exception { + // The public key transparencyKeyCertificate used to create JWS does not match the private key, + // and will result + // in signature verification failure later. + String serializedJws = + createJwsToken( + CodeTransparency.getDefaultInstance(), + CertificateFactory.buildSelfSignedCertificate( + kpg.generateKeyPair(), "CN=CheckTransparencyCommandTest_BadCertificate"), + transparencyPrivateKey); + AppBundleBuilder appBundle = + new AppBundleBuilder() + .addModule("base", module -> module.setManifest(androidManifest("com.test.app"))) + .addMetadataFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())); + new AppBundleSerializer().writeToDisk(appBundle.build(), bundlePath); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); CheckTransparencyCommand.builder() @@ -441,63 +683,225 @@ public void execute_bundleMode_transparencyVerified() throws Exception { .build() .checkTransparency(new PrintStream(outputStream)); - assertThat(new String(outputStream.toByteArray(), UTF_8)) - .contains("Code transparency verified. Public key certificate fingerprint: "); + String output = new String(outputStream.toByteArray(), UTF_8); + assertThat(output).contains("No APK present. APK signature was not checked."); + assertThat(output) + .contains("Verification failed because code transparency signature is invalid."); } @Test - public void execute_bundleMode_unsupportedSignatureAlgorithm() throws Exception { - CodeTransparency validCodeTransparency = - createValidTransparencyProto( - ByteSource.wrap(FILE_CONTENT).hash(Hashing.sha256()).toString()); - createBundle( - bundlePath, - Optional.of(validCodeTransparency), - /* hasSharedUserId= */ false, - RSA_USING_SHA384); + public void apkMode_transparencyVerified_transparencyKeyCertificateNotProvidedByUser() + throws Exception { + Path apkPath = tmpDir.resolve("universal.apk"); + Path zipOfApksPath = tmpDir.resolve("apks.zip"); + String dexFileName = "classes.dex"; + byte[] dexFileContents = new byte[] {1, 2, 3}; + String serializedJws = + createJwsToken( + CodeTransparency.newBuilder() + .addCodeRelatedFile( + CodeRelatedFile.newBuilder() + .setType(CodeRelatedFile.Type.DEX) + .setPath("base/dex/" + dexFileName) + .setSha256( + ByteSource.wrap(dexFileContents).hash(Hashing.sha256()).toString()) + .build()) + .build(), + transparencyKeyCertificate, + transparencyPrivateKey); + ModuleSplit baseModuleSplit = + ModuleSplit.builder() + .setModuleName(BundleModuleName.create("base")) + .setAndroidManifest(AndroidManifest.create(androidManifest("com.app"))) + .setApkTargeting(ApkTargeting.getDefaultInstance()) + .setVariantTargeting(VariantTargeting.getDefaultInstance()) + .setMasterSplit(true) + .addEntry( + ModuleEntry.builder() + .setPath(ZipPath.create("").resolve(dexFileName)) + .setContent(ByteSource.wrap(dexFileContents)) + .build()) + .addEntry( + ModuleEntry.builder() + .setPath( + ZipPath.create("META-INF") + .resolve(BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME)) + .setContent( + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())) + .build()) + .build(); + apkSerializerHelper.writeToZipFile(baseModuleSplit, apkPath); + ZipBuilder zipBuilder = + new ZipBuilder() + .addFileWithContent( + ZipPath.create("universal.apk"), + ByteString.readFrom(Files.newInputStream(apkPath)).toByteArray()); + zipBuilder.writeTo(zipOfApksPath); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Throwable e = - assertThrows( - InvalidBundleException.class, - () -> - CheckTransparencyCommand.builder() - .setMode(Mode.BUNDLE) - .setBundlePath(bundlePath) - .build() - .checkTransparency(new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .contains("Exception while verifying code transparency signature."); + CheckTransparencyCommand.builder() + .setMode(Mode.APK) + .setApkZipPath(zipOfApksPath) + .build() + .checkTransparency(new PrintStream(outputStream)); + + String output = new String(outputStream.toByteArray(), UTF_8); + assertThat(output) + .contains( + "APK signature is valid. SHA-256 fingerprint of the apk signing key certificate (must" + + " be compared with the developer's public key manually): " + + CodeTransparencyCryptoUtils.getCertificateFingerprint(apkSigningKeyCertificate)); + assertThat(output) + .contains( + "Code transparency signature is valid. SHA-256 fingerprint of the code transparency key" + + " certificate (must be compared with the developer's public key manually): " + + CodeTransparencyCryptoUtils.getCertificateFingerprint( + (JsonWebSignature) JsonWebSignature.fromCompactSerialization(serializedJws))); + assertThat(output) + .contains( + "Code transparency verified: code related file contents match the code transparency" + + " file."); } @Test - public void execute_bundleMode_transparencyFilePresent_sharedUserIdSpecifiedInManifest() + public void apkMode_transparencyVerified_apkSigningKeyCertificateProvidedByUser() throws Exception { - CodeTransparency validCodeTransparency = - createValidTransparencyProto( - ByteSource.wrap(FILE_CONTENT).hash(Hashing.sha256()).toString()); - createBundle( - bundlePath, - Optional.of(validCodeTransparency), - /* hasSharedUserId= */ true, - RSA_USING_SHA256); + Path apkPath = tmpDir.resolve("universal.apk"); + Path zipOfApksPath = tmpDir.resolve("apks.zip"); + String dexFileName = "classes.dex"; + byte[] dexFileContents = new byte[] {1, 2, 3}; + String serializedJws = + createJwsToken( + CodeTransparency.newBuilder() + .addCodeRelatedFile( + CodeRelatedFile.newBuilder() + .setType(CodeRelatedFile.Type.DEX) + .setPath("base/dex/" + dexFileName) + .setSha256( + ByteSource.wrap(dexFileContents).hash(Hashing.sha256()).toString()) + .build()) + .build(), + transparencyKeyCertificate, + transparencyPrivateKey); + ModuleSplit baseModuleSplit = + ModuleSplit.builder() + .setModuleName(BundleModuleName.create("base")) + .setAndroidManifest(AndroidManifest.create(androidManifest("com.app"))) + .setApkTargeting(ApkTargeting.getDefaultInstance()) + .setVariantTargeting(VariantTargeting.getDefaultInstance()) + .setMasterSplit(true) + .addEntry( + ModuleEntry.builder() + .setPath(ZipPath.create("").resolve(dexFileName)) + .setContent(ByteSource.wrap(dexFileContents)) + .build()) + .addEntry( + ModuleEntry.builder() + .setPath( + ZipPath.create("META-INF") + .resolve(BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME)) + .setContent( + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())) + .build()) + .build(); + apkSerializerHelper.writeToZipFile(baseModuleSplit, apkPath); + ZipBuilder zipBuilder = + new ZipBuilder() + .addFileWithContent( + ZipPath.create("universal.apk"), + ByteString.readFrom(Files.newInputStream(apkPath)).toByteArray()); + zipBuilder.writeTo(zipOfApksPath); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Throwable e = - assertThrows( - InvalidBundleException.class, - () -> - CheckTransparencyCommand.builder() - .setMode(Mode.BUNDLE) - .setBundlePath(bundlePath) - .build() - .checkTransparency(new PrintStream(outputStream))); - assertThat(e) - .hasMessageThat() - .isEqualTo( - "Transparency file is present in the bundle, but it can not be verified because" - + " `sharedUserId` attribute is specified in one of the manifests."); + CheckTransparencyCommand.builder() + .setMode(Mode.APK) + .setApkZipPath(zipOfApksPath) + .setApkSigningKeyCertificate(apkSigningKeyCertificate) + .build() + .checkTransparency(new PrintStream(outputStream)); + + String output = new String(outputStream.toByteArray(), UTF_8); + assertThat(output) + .contains("APK signature verified for the provided apk signing key certificate."); + assertThat(output) + .contains( + "Code transparency signature is valid. SHA-256 fingerprint of the code transparency key" + + " certificate (must be compared with the developer's public key manually): " + + CodeTransparencyCryptoUtils.getCertificateFingerprint( + (JsonWebSignature) JsonWebSignature.fromCompactSerialization(serializedJws))); + assertThat(output) + .contains( + "Code transparency verified: code related file contents match the code transparency" + + " file."); + } + + @Test + public void apkMode_verificationFailed_apkSigningKeyCertificateMismatch() throws Exception { + Path apkPath = tmpDir.resolve("universal.apk"); + Path zipOfApksPath = tmpDir.resolve("apks.zip"); + String dexFileName = "classes.dex"; + byte[] dexFileContents = new byte[] {1, 2, 3}; + String serializedJws = + createJwsToken( + CodeTransparency.newBuilder() + .addCodeRelatedFile( + CodeRelatedFile.newBuilder() + .setType(CodeRelatedFile.Type.DEX) + .setPath("base/dex/" + dexFileName) + .setSha256( + ByteSource.wrap(dexFileContents).hash(Hashing.sha256()).toString()) + .build()) + .build(), + transparencyKeyCertificate, + transparencyPrivateKey); + ModuleSplit baseModuleSplit = + ModuleSplit.builder() + .setModuleName(BundleModuleName.create("base")) + .setAndroidManifest(AndroidManifest.create(androidManifest("com.app"))) + .setApkTargeting(ApkTargeting.getDefaultInstance()) + .setVariantTargeting(VariantTargeting.getDefaultInstance()) + .setMasterSplit(true) + .addEntry( + ModuleEntry.builder() + .setPath(ZipPath.create("").resolve(dexFileName)) + .setContent(ByteSource.wrap(dexFileContents)) + .build()) + .addEntry( + ModuleEntry.builder() + .setPath( + ZipPath.create("META-INF") + .resolve(BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME)) + .setContent( + CharSource.wrap(serializedJws).asByteSource(Charset.defaultCharset())) + .build()) + .build(); + apkSerializerHelper.writeToZipFile(baseModuleSplit, apkPath); + ZipBuilder zipBuilder = + new ZipBuilder() + .addFileWithContent( + ZipPath.create("universal.apk"), + ByteString.readFrom(Files.newInputStream(apkPath)).toByteArray()); + zipBuilder.writeTo(zipOfApksPath); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + X509Certificate providedApkSigningKeyCert = + CertificateFactory.buildSelfSignedCertificate(kpg.genKeyPair(), "CN=BadApkSigningCert"); + + CheckTransparencyCommand.builder() + .setMode(Mode.APK) + .setApkZipPath(zipOfApksPath) + .setApkSigningKeyCertificate(providedApkSigningKeyCert) + .build() + .checkTransparency(new PrintStream(outputStream)); + + String output = new String(outputStream.toByteArray(), UTF_8); + assertThat(output) + .contains( + "APK signature verification failed because the provided public key certificate does" + + " not match the APK signature." + + "\nSHA-256 fingerprint of the certificate that was used to sign the APKs: " + + CodeTransparencyCryptoUtils.getCertificateFingerprint(apkSigningKeyCertificate) + + "\nSHA-256 fingerprint of the certificate that was provided: " + + CodeTransparencyCryptoUtils.getCertificateFingerprint(providedApkSigningKeyCert)); } @Test @@ -505,98 +909,17 @@ public void printHelpDoesNotCrash() { CheckTransparencyCommand.help(); } - private void createBundle(Path path, Optional transparencyMetadata) - throws Exception { - createBundle(path, transparencyMetadata, /* hasSharedUserId= */ false, RSA_USING_SHA256); - } + @CommandScoped + @Component(modules = {BuildApksModule.class, TestModule.class}) + interface TestComponent { - private void createBundle( - Path path, - Optional transparencyMetadata, - boolean hasSharedUserId, - String algorithmIdentifier) - throws Exception { - AppBundleBuilder appBundle = - new AppBundleBuilder() - .addModule(BASE_MODULE, module -> addCodeFilesToBundleModule(module, hasSharedUserId)) - .addModule( - FEATURE_MODULE1, module -> addCodeFilesToBundleModule(module, hasSharedUserId)) - .addModule( - FEATURE_MODULE2, module -> addCodeFilesToBundleModule(module, hasSharedUserId)); - if (transparencyMetadata.isPresent()) { - appBundle.addMetadataFile( - BundleMetadata.BUNDLETOOL_NAMESPACE, - BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, - CharSource.wrap( - createJwsToken( - JsonFormat.printer().print(transparencyMetadata.get()), algorithmIdentifier)) - .asByteSource(Charset.defaultCharset())); + void inject(CheckTransparencyCommandTest test); + + static void useTestModule(CheckTransparencyCommandTest testInstance, TestModule testModule) { + DaggerCheckTransparencyCommandTest_TestComponent.builder() + .testModule(testModule) + .build() + .inject(testInstance); } - new AppBundleSerializer().writeToDisk(appBundle.build(), path); - } - - private static BundleModule addCodeFilesToBundleModule( - BundleModuleBuilder module, boolean hasSharedUserId) { - XmlNode manifest = - hasSharedUserId - ? androidManifest("com.test.app", withSharedUserId("sharedUserId")) - : androidManifest("com.test.app"); - return module - .setManifest(manifest) - .addFile(DEX_PATH1, FILE_CONTENT) - .addFile(DEX_PATH2, FILE_CONTENT) - .addFile(NATIVE_LIB_PATH1, FILE_CONTENT) - .addFile(NATIVE_LIB_PATH2, FILE_CONTENT) - .build(); - } - - private static CodeTransparency createValidTransparencyProto(String fileContentHash) { - CodeTransparency.Builder transparencyBuilder = CodeTransparency.newBuilder(); - addCodeFilesToTransparencyProto(transparencyBuilder, BASE_MODULE, fileContentHash); - addCodeFilesToTransparencyProto(transparencyBuilder, FEATURE_MODULE1, fileContentHash); - addCodeFilesToTransparencyProto(transparencyBuilder, FEATURE_MODULE2, fileContentHash); - return transparencyBuilder.build(); - } - - private static void addCodeFilesToTransparencyProto( - CodeTransparency.Builder transparencyBuilder, String moduleName, String fileContentHash) { - transparencyBuilder - .addCodeRelatedFile( - CodeRelatedFile.newBuilder() - .setPath(moduleName + "/" + DEX_PATH1) - .setType(CodeRelatedFile.Type.DEX) - .setApkPath("") - .setSha256(fileContentHash) - .build()) - .addCodeRelatedFile( - CodeRelatedFile.newBuilder() - .setPath(moduleName + "/" + DEX_PATH2) - .setType(CodeRelatedFile.Type.DEX) - .setApkPath("") - .setSha256(fileContentHash) - .build()) - .addCodeRelatedFile( - CodeRelatedFile.newBuilder() - .setPath(moduleName + "/" + NATIVE_LIB_PATH1) - .setType(CodeRelatedFile.Type.NATIVE_LIBRARY) - .setApkPath(NATIVE_LIB_PATH1) - .setSha256(fileContentHash) - .build()) - .addCodeRelatedFile( - CodeRelatedFile.newBuilder() - .setPath(moduleName + "/" + NATIVE_LIB_PATH2) - .setType(CodeRelatedFile.Type.NATIVE_LIBRARY) - .setApkPath(NATIVE_LIB_PATH2) - .setSha256(fileContentHash) - .build()); - } - - private String createJwsToken(String payload, String algorithmIdentifier) throws JoseException { - JsonWebSignature jws = new JsonWebSignature(); - jws.setAlgorithmHeaderValue(algorithmIdentifier); - jws.setCertificateChainHeaderValue(certificate); - jws.setPayload(payload); - jws.setKey(privateKey); - return jws.getCompactSerialization(); } } diff --git a/src/test/java/com/android/tools/build/bundletool/commands/ExtractApksCommandTest.java b/src/test/java/com/android/tools/build/bundletool/commands/ExtractApksCommandTest.java index 53493ea7..f7d9b0e5 100644 --- a/src/test/java/com/android/tools/build/bundletool/commands/ExtractApksCommandTest.java +++ b/src/test/java/com/android/tools/build/bundletool/commands/ExtractApksCommandTest.java @@ -67,6 +67,8 @@ import com.android.bundle.Commands.DeliveryType; import com.android.bundle.Commands.ExtractApksResult; import com.android.bundle.Commands.ExtractedApk; +import com.android.bundle.Commands.LocalTestingInfo; +import com.android.bundle.Commands.LocalTestingInfoForMetadata; import com.android.bundle.Config.Bundletool; import com.android.bundle.Config.SplitDimension.Value; import com.android.bundle.Devices.DeviceSpec; @@ -1899,11 +1901,18 @@ public void incompleteApksFile_missingMatchedAbiSplit_throws() throws Exception "Missing APKs for [ABI] dimensions in the module 'base' for the provided device."); } + @DataPoints("localTestingEnabled") + public static final ImmutableSet LOCAL_TESTING_ENABLED = ImmutableSet.of(true, false); + @Test - public void extractApks_producesOutputMetadata() throws Exception { + @Theory + public void extractApks_producesOutputMetadata( + @FromDataPoints("localTestingEnabled") boolean localTestingEnabled) throws Exception { + String onDemandModule = "ondemand_assetmodule"; ZipPath apkBase = ZipPath.create("base-master.apk"); ZipPath apkFeature1 = ZipPath.create("feature1-master.apk"); ZipPath apkFeature2 = ZipPath.create("feature2-master.apk"); + ZipPath onDemandMasterApk = ZipPath.create(onDemandModule + "-master.apk"); BuildApksResult tableOfContentsProto = BuildApksResult.newBuilder() .setBundletool( @@ -1927,7 +1936,26 @@ public void extractApks_producesOutputMetadata() throws Exception { /* moduleDependencies= */ ImmutableList.of("feature1"), createMasterApkDescription( ApkTargeting.getDefaultInstance(), apkFeature2)))) + .addAssetSliceSet( + AssetSliceSet.newBuilder() + .setAssetModuleMetadata( + AssetModuleMetadata.newBuilder() + .setName(onDemandModule) + .setDeliveryType(DeliveryType.ON_DEMAND)) + .addApkDescription( + splitApkDescription(ApkTargeting.getDefaultInstance(), onDemandMasterApk))) .build(); + + if (localTestingEnabled) { + tableOfContentsProto = + tableOfContentsProto.toBuilder() + .setPackageName("com.acme.anvil") + .setLocalTestingInfo( + LocalTestingInfo.newBuilder() + .setEnabled(true) + .setLocalTestingPath("local_testing_dir")) + .build(); + } Path apksArchiveFile = createApksArchiveFile(tableOfContentsProto, tmpDir.resolve("bundle.apks")); @@ -1938,7 +1966,7 @@ public void extractApks_producesOutputMetadata() throws Exception { .setApksArchivePath(apksArchiveFile) .setDeviceSpec(deviceSpec) .setOutputDirectory(tmpDir) - .setModules(ImmutableSet.of("feature2")) + .setModules(ImmutableSet.of("feature2", "ondemand_assetmodule")) .setIncludeMetadata(true) .build() .execute(); @@ -1947,33 +1975,50 @@ public void extractApks_producesOutputMetadata() throws Exception { .containsExactly( inOutputDirectory(apkBase), inOutputDirectory(apkFeature1), - inOutputDirectory(apkFeature2)); + inOutputDirectory(apkFeature2), + inOutputDirectory(onDemandMasterApk)); Path metadataFile = inTempDirectory("metadata.json"); assertThat(Files.isRegularFile(metadataFile)).isTrue(); + ExtractApksResult expectedResult = + ExtractApksResult.newBuilder() + .addApks( + ExtractedApk.newBuilder() + .setPath(apkBase.toString()) + .setDeliveryType(DeliveryType.INSTALL_TIME) + .setModuleName("base") + .build()) + .addApks( + ExtractedApk.newBuilder() + .setPath(apkFeature1.toString()) + .setDeliveryType(DeliveryType.ON_DEMAND) + .setModuleName("feature1") + .build()) + .addApks( + ExtractedApk.newBuilder() + .setPath(apkFeature2.toString()) + .setDeliveryType(DeliveryType.ON_DEMAND) + .setModuleName("feature2") + .build()) + .addApks( + ExtractedApk.newBuilder() + .setPath(onDemandMasterApk.toString()) + .setDeliveryType(DeliveryType.ON_DEMAND) + .setModuleName("ondemand_assetmodule") + .build()) + .build(); + if (localTestingEnabled) { + expectedResult = + expectedResult.toBuilder() + .setLocalTestingInfo( + LocalTestingInfoForMetadata.newBuilder() + .setLocalTestingDir( + "/sdcard/Android/data/com.acme.anvil/files/local_testing_dir")) + .build(); + } assertThat(parseExtractApksResult(metadataFile)) .ignoringRepeatedFieldOrder() - .isEqualTo( - ExtractApksResult.newBuilder() - .addApks( - ExtractedApk.newBuilder() - .setPath(apkBase.toString()) - .setDeliveryType(DeliveryType.INSTALL_TIME) - .setModuleName("base") - .build()) - .addApks( - ExtractedApk.newBuilder() - .setPath(apkFeature1.toString()) - .setDeliveryType(DeliveryType.ON_DEMAND) - .setModuleName("feature1") - .build()) - .addApks( - ExtractedApk.newBuilder() - .setPath(apkFeature2.toString()) - .setDeliveryType(DeliveryType.ON_DEMAND) - .setModuleName("feature2") - .build()) - .build()); + .isEqualTo(expectedResult); } private static ExtractApksResult parseExtractApksResult(Path file) throws Exception { diff --git a/src/test/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommandTest.java b/src/test/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommandTest.java index 78550f50..4d5c6575 100644 --- a/src/test/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommandTest.java +++ b/src/test/java/com/android/tools/build/bundletool/commands/GetDeviceSpecCommandTest.java @@ -40,8 +40,10 @@ import com.android.tools.build.bundletool.testing.FakeAdbServer; import com.android.tools.build.bundletool.testing.FakeDevice; import com.android.tools.build.bundletool.testing.FakeSystemEnvironmentProvider; +import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.protobuf.util.JsonFormat; import java.io.IOException; import java.io.Reader; @@ -62,6 +64,8 @@ public class GetDeviceSpecCommandTest { private Path tmpDir; private static final String DEVICE_ID = "id1"; private static final String DEVICE_TIER = "low"; + private static final ImmutableSet DEVICE_GROUPS = + ImmutableSet.of("highRam", "googlePixel"); private SystemEnvironmentProvider systemEnvironmentProvider; private Path adbPath; @@ -139,7 +143,8 @@ public void fromFlagsEquivalentToBuilder_allFlagsUsed() { "--adb=" + adbPath, "--device-id=" + DEVICE_ID, "--output=" + outputPath, - "--device-tier=" + DEVICE_TIER), + "--device-tier=" + DEVICE_TIER, + "--device-groups=" + Joiner.on(",").join(DEVICE_GROUPS)), systemEnvironmentProvider, fakeServerOneDevice(lDeviceWithLocales("en-US"))); @@ -150,6 +155,7 @@ public void fromFlagsEquivalentToBuilder_allFlagsUsed() { .setOutputPath(outputPath) .setAdbServer(commandViaFlags.getAdbServer()) .setDeviceTier(DEVICE_TIER) + .setDeviceGroups(DEVICE_GROUPS) .build(); assertThat(commandViaFlags).isEqualTo(commandViaBuilder); diff --git a/src/test/java/com/android/tools/build/bundletool/device/ApkMatcherTest.java b/src/test/java/com/android/tools/build/bundletool/device/ApkMatcherTest.java index 83a03f08..8b20d1e9 100644 --- a/src/test/java/com/android/tools/build/bundletool/device/ApkMatcherTest.java +++ b/src/test/java/com/android/tools/build/bundletool/device/ApkMatcherTest.java @@ -43,7 +43,7 @@ import static com.android.tools.build.bundletool.testing.DeviceFactory.abis; import static com.android.tools.build.bundletool.testing.DeviceFactory.density; import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceFeatures; -import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceTier; +import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceGroups; import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceWithSdk; import static com.android.tools.build.bundletool.testing.DeviceFactory.lDevice; import static com.android.tools.build.bundletool.testing.DeviceFactory.lDeviceWithAbis; @@ -63,7 +63,7 @@ import static com.android.tools.build.bundletool.testing.TargetingUtils.mergeApkTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.mergeModuleTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.mergeVariantTargeting; -import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceTiersTargeting; +import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceGroupsTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleFeatureTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleMinSdkVersionTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.multiAbiTargeting; @@ -1288,7 +1288,7 @@ public void splitApk_conditionalModule_deviceEligible() { mergeSpecs( deviceWithSdk(24), deviceFeatures("android.hardware.camera.ar", "reqGlEsVersion=0x30002"), - deviceTier("high")); + deviceGroups("highRam")); ZipPath baseApk = ZipPath.create("base-master.apk"); ZipPath feature1Apk = ZipPath.create("ar-master.apk"); @@ -1316,7 +1316,7 @@ public void splitApk_conditionalModule_deviceEligible() { createConditionalApkSet( /* moduleName= */ "high_end", mergeModuleTargeting( - moduleDeviceTiersTargeting("high"), moduleMinSdkVersionTargeting(21)), + moduleDeviceGroupsTargeting("highRam"), moduleMinSdkVersionTargeting(21)), splitApkDescription(ApkTargeting.getDefaultInstance(), feature3Apk)))); assertThat(new ApkMatcher(device).getMatchingApks(buildApksResult)) .containsExactly( @@ -1356,7 +1356,7 @@ public void splitApk_conditionalModule_deviceNotEligible() { createConditionalApkSet( /* moduleName= */ "high_end", mergeModuleTargeting( - moduleDeviceTiersTargeting("high"), moduleMinSdkVersionTargeting(21)), + moduleDeviceGroupsTargeting("highRam"), moduleMinSdkVersionTargeting(21)), splitApkDescription(ApkTargeting.getDefaultInstance(), feature3Apk)))); assertThat(new ApkMatcher(device).getMatchingApks(buildApksResult)) .containsExactly(baseMatchedApk(baseApk)); diff --git a/src/test/java/com/android/tools/build/bundletool/device/DdmlibDeviceTest.java b/src/test/java/com/android/tools/build/bundletool/device/DdmlibDeviceTest.java index 5df05246..4b85f41e 100644 --- a/src/test/java/com/android/tools/build/bundletool/device/DdmlibDeviceTest.java +++ b/src/test/java/com/android/tools/build/bundletool/device/DdmlibDeviceTest.java @@ -32,6 +32,7 @@ import com.android.sdklib.AndroidVersion; import com.android.sdklib.AndroidVersion.VersionCodes; import com.android.tools.build.bundletool.device.DdmlibDevice.RemoteCommandExecutor; +import com.android.tools.build.bundletool.device.Device.FilePullParams; import com.android.tools.build.bundletool.device.Device.InstallOptions; import com.android.tools.build.bundletool.device.Device.PushOptions; import com.google.common.collect.ImmutableList; @@ -177,6 +178,21 @@ public void pushFiles_tempLocation() throws Exception { .pushFile(APK_PATH_2.toFile().getAbsolutePath(), tempPath + "/" + APK_PATH_2.getFileName()); } + @Test + public void pullFiles() throws Exception { + Path destinationPath = Paths.get("/destination/path", APK_PATH.getFileName().toString()); + DdmlibDevice ddmlibDevice = new DdmlibDevice(mockDevice); + + ddmlibDevice.pull( + ImmutableList.of( + FilePullParams.builder() + .setPathOnDevice(APK_PATH.toFile().getAbsolutePath()) + .setDestinationPath(destinationPath) + .build())); + + verify(mockDevice).pullFile(APK_PATH.toFile().getAbsolutePath(), destinationPath.toString()); + } + private void mockAdbShellCommand(String command, String response) throws Exception { Mockito.doAnswer( invocation -> { diff --git a/src/test/java/com/android/tools/build/bundletool/device/DeviceTierModuleMatcherTest.java b/src/test/java/com/android/tools/build/bundletool/device/DeviceGroupModuleMatcherTest.java similarity index 78% rename from src/test/java/com/android/tools/build/bundletool/device/DeviceTierModuleMatcherTest.java rename to src/test/java/com/android/tools/build/bundletool/device/DeviceGroupModuleMatcherTest.java index d50c5d41..7047aa3f 100644 --- a/src/test/java/com/android/tools/build/bundletool/device/DeviceTierModuleMatcherTest.java +++ b/src/test/java/com/android/tools/build/bundletool/device/DeviceGroupModuleMatcherTest.java @@ -15,8 +15,8 @@ */ package com.android.tools.build.bundletool.device; -import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceTier; -import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceTiersTargeting; +import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceGroups; +import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceGroupsTargeting; import static com.google.common.truth.Truth.assertThat; import com.android.bundle.Devices.DeviceSpec; @@ -26,18 +26,19 @@ import org.junit.runners.JUnit4; @RunWith(JUnit4.class) -public class DeviceTierModuleMatcherTest { +public class DeviceGroupModuleMatcherTest { @Test public void matchesTargeting_specifiedDeviceTier() { - DeviceTierModuleMatcher matcher = new DeviceTierModuleMatcher(deviceTier("medium")); + DeviceGroupModuleMatcher matcher = + new DeviceGroupModuleMatcher(deviceGroups("highRam", "mediumRam")); assertThat( matcher .getModuleTargetingPredicate() - .test(moduleDeviceTiersTargeting("medium", "high"))) + .test(moduleDeviceGroupsTargeting("mediumRam", "googlePixel"))) .isTrue(); - assertThat(matcher.getModuleTargetingPredicate().test(moduleDeviceTiersTargeting("low"))) + assertThat(matcher.getModuleTargetingPredicate().test(moduleDeviceGroupsTargeting("lowRam"))) .isFalse(); assertThat(matcher.getModuleTargetingPredicate().test(ModuleTargeting.getDefaultInstance())) .isTrue(); @@ -45,9 +46,10 @@ public void matchesTargeting_specifiedDeviceTier() { @Test public void matchesTargeting_noTierInDeviceSpec() { - DeviceTierModuleMatcher matcher = new DeviceTierModuleMatcher(DeviceSpec.getDefaultInstance()); + DeviceGroupModuleMatcher matcher = + new DeviceGroupModuleMatcher(DeviceSpec.getDefaultInstance()); - assertThat(matcher.getModuleTargetingPredicate().test(moduleDeviceTiersTargeting("medium"))) + assertThat(matcher.getModuleTargetingPredicate().test(moduleDeviceGroupsTargeting("highRam"))) .isFalse(); assertThat(matcher.getModuleTargetingPredicate().test(ModuleTargeting.getDefaultInstance())) .isTrue(); diff --git a/src/test/java/com/android/tools/build/bundletool/device/LocalTestingPathResolverTest.java b/src/test/java/com/android/tools/build/bundletool/device/LocalTestingPathResolverTest.java new file mode 100644 index 00000000..7bc90de9 --- /dev/null +++ b/src/test/java/com/android/tools/build/bundletool/device/LocalTestingPathResolverTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tools.build.bundletool.device; + +import static com.android.tools.build.bundletool.device.LocalTestingPathResolver.resolveLocalTestingPath; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.android.tools.build.bundletool.model.exceptions.CommandExecutionException; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class LocalTestingPathResolverTest { + + @Test + public void resolveLocalTestingPath_relativePathAndPackageName_resolves() { + String actual = resolveLocalTestingPath("foo", Optional.of("com.acme.anvil")); + + assertThat(actual).isEqualTo("/sdcard/Android/data/com.acme.anvil/files/foo"); + } + + @Test + public void resolveLocalTestingPath_relativePathWithoutPackageName_throws() { + assertThrows( + CommandExecutionException.class, () -> resolveLocalTestingPath("foo", Optional.empty())); + } + + @Test + public void resolveLocalTestingPath_absolutePath_resolves() { + String actual = resolveLocalTestingPath("/foo/bar", Optional.of("com.acme.anvil")); + + assertThat(actual).isEqualTo("/foo/bar"); + } +} diff --git a/src/test/java/com/android/tools/build/bundletool/device/ModuleMatcherTest.java b/src/test/java/com/android/tools/build/bundletool/device/ModuleMatcherTest.java index 1ace51d6..b20443f7 100644 --- a/src/test/java/com/android/tools/build/bundletool/device/ModuleMatcherTest.java +++ b/src/test/java/com/android/tools/build/bundletool/device/ModuleMatcherTest.java @@ -18,11 +18,11 @@ import static com.android.tools.build.bundletool.device.OpenGlFeatureMatcher.CONDITIONAL_MODULES_OPEN_GL_NAME; import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceFeatures; -import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceTier; +import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceGroups; import static com.android.tools.build.bundletool.testing.DeviceFactory.deviceWithSdk; import static com.android.tools.build.bundletool.testing.DeviceFactory.mergeSpecs; import static com.android.tools.build.bundletool.testing.TargetingUtils.mergeModuleTargeting; -import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceTiersTargeting; +import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceGroupsTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleFeatureTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleMinSdkVersionTargeting; import static com.google.common.truth.Truth.assertThat; @@ -51,12 +51,12 @@ public void matchesModuleTargeting_positive() { moduleMinSdkVersionTargeting(21), moduleFeatureTargeting("feature1"), moduleFeatureTargeting(CONDITIONAL_MODULES_OPEN_GL_NAME, 0x30001), - moduleDeviceTiersTargeting("high")); + moduleDeviceGroupsTargeting("highRam")); DeviceSpec deviceSpec = mergeSpecs( deviceWithSdk(22), deviceFeatures("feature1", "feature2", "reqGlEsVersion=0x30002"), - deviceTier("high")); + deviceGroups("highRam")); ModuleMatcher moduleMatcher = new ModuleMatcher(deviceSpec); assertThat(moduleMatcher.matchesModuleTargeting(targeting)).isTrue(); @@ -101,8 +101,8 @@ public void matchesModuleTargeting_negative_wrongDeviceTier() { mergeModuleTargeting( moduleMinSdkVersionTargeting(21), moduleFeatureTargeting("feature1"), - moduleDeviceTiersTargeting("high")); - DeviceSpec deviceSpec = mergeSpecs(deviceWithSdk(22), deviceTier("low")); + moduleDeviceGroupsTargeting("highRam")); + DeviceSpec deviceSpec = mergeSpecs(deviceWithSdk(22), deviceGroups("lowRam")); ModuleMatcher moduleMatcher = new ModuleMatcher(deviceSpec); assertThat(moduleMatcher.matchesModuleTargeting(targeting)).isFalse(); diff --git a/src/test/java/com/android/tools/build/bundletool/mergers/D8DexMergerTest.java b/src/test/java/com/android/tools/build/bundletool/mergers/D8DexMergerTest.java index 43f14766..92242ed6 100644 --- a/src/test/java/com/android/tools/build/bundletool/mergers/D8DexMergerTest.java +++ b/src/test/java/com/android/tools/build/bundletool/mergers/D8DexMergerTest.java @@ -230,16 +230,41 @@ public void mergeDoesNotFitIntoSingleDex_withMainDexList_preL_ok() throws Except .isEqualTo(listClassesInDexFiles(dexFile1, dexFile2)); } + @Test + public void mergeCoreDesugaringLibrary_ok() throws Exception { + // Two application dex files together with code desugaring dex. + Path dexFile1 = writeTestDataToFile("testdata/dex/classes.dex"); + Path dexFile2 = writeTestDataToFile("testdata/dex/classes-other.dex"); + Path dexFile3 = writeTestDataToFile("testdata/dex/classes-emulated-coredesugar.dex"); + + ImmutableList mergedDexFiles = + new D8DexMerger() + .merge( + ImmutableList.of(dexFile1, dexFile2, dexFile3), + outputDir, + /* mainDexListFile= */ Optional.empty(), + /* proguardMap= */ NO_FILE, + /* isDebuggable= */ false, + /* minSdkVersion= */ ANDROID_K_API_VERSION); + ImmutableList mergedDexFilenames = + mergedDexFiles.stream().map(dex -> dex.getFileName().toString()).collect(toImmutableList()); + + assertThat(mergedDexFiles.size()).isAtLeast(2); + assertThat(mergedDexFilenames).containsExactly("classes.dex", "classes2.dex"); + assertThat(listClassesInDexFiles(mergedDexFiles.get(0))) + .isEqualTo(listClassesInDexFiles(dexFile1, dexFile2)); + // Core desugaring dex must not be merged with application dex. + assertThat(Files.readAllBytes(mergedDexFiles.get(1))).isEqualTo(Files.readAllBytes(dexFile3)); + } + @Test public void bogusMapFileWorks() throws Exception { // The two input dex files cannot fit into a single dex file. Path dexFile1 = writeTestDataToFile("testdata/dex/classes-large.dex"); Path dexFile2 = writeTestDataToFile("testdata/dex/classes-large2.dex"); - Optional bogusMapFile = - Optional.of( - FileUtils.createFileWithLines( - tmp, "NOT_A_VALID::MAPPING->::file->x")); + Optional bogusMapFile = + Optional.of(FileUtils.createFileWithLines(tmp, "NOT_A_VALID::MAPPING->::file->x")); ImmutableList mergedDexFiles = new D8DexMerger() @@ -263,7 +288,7 @@ public void mapFileArgument() throws Exception { // We are not testing actual distribution, just that a valid map // file is passed through correctly. - Optional mapFile = + Optional mapFile = Optional.of( FileUtils.createFileWithLines( tmp, @@ -289,7 +314,6 @@ public void mapFileArgument() throws Exception { assertThat(mergedDexFiles.size()).isAtLeast(2); assertThat(listClassesInDexFiles(mergedDexFiles)) .isEqualTo(listClassesInDexFiles(dexFile1, dexFile2)); - } private Path writeTestDataToFile(String testDataPath) throws Exception { diff --git a/src/test/java/com/android/tools/build/bundletool/model/AndroidManifestTest.java b/src/test/java/com/android/tools/build/bundletool/model/AndroidManifestTest.java index 09ea691b..6fc1760e 100644 --- a/src/test/java/com/android/tools/build/bundletool/model/AndroidManifestTest.java +++ b/src/test/java/com/android/tools/build/bundletool/model/AndroidManifestTest.java @@ -29,10 +29,12 @@ import static com.android.tools.build.bundletool.model.AndroidManifest.VERSION_NAME_RESOURCE_ID; import static com.android.tools.build.bundletool.model.BundleModule.ModuleType.ASSET_MODULE; import static com.android.tools.build.bundletool.model.BundleModule.ModuleType.FEATURE_MODULE; +import static com.android.tools.build.bundletool.model.BundleModule.ModuleType.ML_MODULE; import static com.android.tools.build.bundletool.model.ModuleDeliveryType.ALWAYS_INITIAL_INSTALL; import static com.android.tools.build.bundletool.model.ModuleDeliveryType.NO_INITIAL_INSTALL; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifestForAssetModule; +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifestForMlModule; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withCustomThemeActivity; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFastFollowDelivery; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFusingAttribute; @@ -591,6 +593,15 @@ public void moduleTypeAttribute_featureModule() { assertThat(manifest.getModuleType()).isEqualTo(FEATURE_MODULE); } + @Test + public void moduleTypeAttribute_mlModule() { + AndroidManifest manifest = AndroidManifest.create(androidManifestForMlModule("com.test.app")); + + assertThat(manifest.getOptionalModuleType()).isPresent(); + assertThat(manifest.getOptionalModuleType()).hasValue(ML_MODULE); + assertThat(manifest.getModuleType()).isEqualTo(ML_MODULE); + } + @Test public void moduleTypeAttribute_defaultsToFeatureModule() { AndroidManifest manifest = AndroidManifest.create(androidManifest("com.test.app")); diff --git a/src/test/java/com/android/tools/build/bundletool/model/AppBundleTest.java b/src/test/java/com/android/tools/build/bundletool/model/AppBundleTest.java index a2af31f5..74d45cff 100644 --- a/src/test/java/com/android/tools/build/bundletool/model/AppBundleTest.java +++ b/src/test/java/com/android/tools/build/bundletool/model/AppBundleTest.java @@ -35,6 +35,7 @@ import com.android.bundle.Targeting.Abi.AbiAlias; import com.android.tools.build.bundletool.io.ZipBuilder; import com.android.tools.build.bundletool.model.ModuleEntry.ModuleEntryBundleLocation; +import com.android.tools.build.bundletool.model.exceptions.InvalidBundleException; import com.android.tools.build.bundletool.testing.AppBundleBuilder; import com.android.tools.build.bundletool.testing.BundleConfigBuilder; import com.google.common.io.ByteSource; @@ -216,7 +217,7 @@ public void manifestRequired() throws Exception { .writeTo(bundleFile); try (ZipFile appBundleZip = new ZipFile(bundleFile.toFile())) { - assertThrows(IllegalStateException.class, () -> AppBundle.buildFromZip(appBundleZip)); + assertThrows(InvalidBundleException.class, () -> AppBundle.buildFromZip(appBundleZip)); } } @@ -374,7 +375,7 @@ public void isAssetOnly() throws Exception { } @Test - public void hasSharedUserId() { + public void hasSharedUserId_specifiedInBaseModule_returnsTrue() { AppBundle appBundle = new AppBundleBuilder() .addModule( @@ -384,8 +385,11 @@ public void hasSharedUserId() { androidManifest(PACKAGE_NAME, withSharedUserId("shared_user_id")))) .build(); assertThat(appBundle.hasSharedUserId()).isTrue(); + } - AppBundle appBundle2 = + @Test + public void hasSharedUserId_specifiedInFeatureModule_returnsFalse() { + AppBundle appBundle = new AppBundleBuilder() .addModule("base", baseModule -> baseModule.setManifest(androidManifest(PACKAGE_NAME))) .addModule( @@ -395,13 +399,16 @@ public void hasSharedUserId() { androidManifestForFeature( PACKAGE_NAME, withSharedUserId("shared_user_id")))) .build(); - assertThat(appBundle2.hasSharedUserId()).isTrue(); + assertThat(appBundle.hasSharedUserId()).isFalse(); + } - AppBundle appBundle3 = + @Test + public void hasSharedUserId_unSpecified_returnsFalse() { + AppBundle appBundle = new AppBundleBuilder() .addModule("base", baseModule -> baseModule.setManifest(androidManifest(PACKAGE_NAME))) .build(); - assertThat(appBundle3.hasSharedUserId()).isFalse(); + assertThat(appBundle.hasSharedUserId()).isFalse(); } @Test diff --git a/src/test/java/com/android/tools/build/bundletool/model/BundleModuleTest.java b/src/test/java/com/android/tools/build/bundletool/model/BundleModuleTest.java index 52410d83..653b66cf 100644 --- a/src/test/java/com/android/tools/build/bundletool/model/BundleModuleTest.java +++ b/src/test/java/com/android/tools/build/bundletool/model/BundleModuleTest.java @@ -21,6 +21,7 @@ import static com.android.tools.build.bundletool.model.ModuleDeliveryType.NO_INITIAL_INSTALL; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifestForAssetModule; +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifestForMlModule; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFeatureCondition; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFusingAttribute; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withInstant; @@ -431,7 +432,7 @@ public void getModuleType_feature() { } @Test - public void getModuleType_remoteAsset() { + public void getModuleType_assetModule() { BundleModule bundleModule = createMinimalModuleBuilder() .setAndroidManifestProto(androidManifestForAssetModule("com.test.app")) @@ -440,6 +441,16 @@ public void getModuleType_remoteAsset() { assertThat(bundleModule.getModuleType()).isEqualTo(ModuleType.ASSET_MODULE); } + @Test + public void getModuleType_mlModule() { + BundleModule bundleModule = + createMinimalModuleBuilder() + .setAndroidManifestProto(androidManifestForMlModule("com.test.app")) + .build(); + + assertThat(bundleModule.getModuleType()).isEqualTo(ModuleType.ML_MODULE); + } + @Test public void renderscriptFiles_present() throws Exception { BundleModule bundleModule = diff --git a/src/test/java/com/android/tools/build/bundletool/model/ManifestDeliveryElementTest.java b/src/test/java/com/android/tools/build/bundletool/model/ManifestDeliveryElementTest.java index 994d56d2..58ce3369 100644 --- a/src/test/java/com/android/tools/build/bundletool/model/ManifestDeliveryElementTest.java +++ b/src/test/java/com/android/tools/build/bundletool/model/ManifestDeliveryElementTest.java @@ -17,7 +17,7 @@ package com.android.tools.build.bundletool.model; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; -import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withDeviceTiersCondition; +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withDeviceGroupsCondition; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withEmptyDeliveryElement; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFastFollowDelivery; import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.withFeatureCondition; @@ -370,28 +370,28 @@ public void moduleConditions_typoInElement_throws() { } @Test - public void moduleConditions_deviceTiersCondition() { + public void moduleConditions_deviceGroupsCondition() { Optional deliveryElement = ManifestDeliveryElement.fromManifestRootNode( androidManifest( - "com.test.app", withDeviceTiersCondition(ImmutableList.of("medium", "high"))), + "com.test.app", withDeviceGroupsCondition(ImmutableList.of("group1", "group2"))), /* isFastFollowAllowed= */ false); assertThat(deliveryElement).isPresent(); assertThat(deliveryElement.get().hasModuleConditions()).isTrue(); - assertThat(deliveryElement.get().getModuleConditions().getDeviceTiersCondition()) - .hasValue(DeviceTiersCondition.create(ImmutableSet.of("medium", "high"))); + assertThat(deliveryElement.get().getModuleConditions().getDeviceGroupsCondition()) + .hasValue(DeviceGroupsCondition.create(ImmutableSet.of("group1", "group2"))); } @Test - public void moduleConditions_multipleDeviceTiersCondition_throws() { + public void moduleConditions_multipleDeviceGroupsCondition_throws() { Optional element = ManifestDeliveryElement.fromManifestRootNode( androidManifest( "com.test.app", - withDeviceTiersCondition(ImmutableList.of("medium", "high")), - withDeviceTiersCondition(ImmutableList.of("low"))), + withDeviceGroupsCondition(ImmutableList.of("group1", "group2")), + withDeviceGroupsCondition(ImmutableList.of("group3"))), /* isFastFollowAllowed= */ false); assertThat(element).isPresent(); @@ -400,14 +400,14 @@ public void moduleConditions_multipleDeviceTiersCondition_throws() { assertThrows(InvalidBundleException.class, () -> element.get().getModuleConditions()); assertThat(exception) .hasMessageThat() - .contains("Multiple '' conditions are not supported."); + .contains("Multiple '' conditions are not supported."); } @Test - public void moduleConditions_emptyDeviceTiersCondition_throws() { + public void moduleConditions_emptyDeviceGroupsCondition_throws() { Optional element = ManifestDeliveryElement.fromManifestRootNode( - androidManifest("com.test.app", withDeviceTiersCondition(ImmutableList.of())), + androidManifest("com.test.app", withDeviceGroupsCondition(ImmutableList.of())), /* isFastFollowAllowed= */ false); assertThat(element).isPresent(); @@ -416,19 +416,20 @@ public void moduleConditions_emptyDeviceTiersCondition_throws() { assertThrows(InvalidBundleException.class, () -> element.get().getModuleConditions()); assertThat(exception) .hasMessageThat() - .contains("At least one device tier should be specified in '' element."); + .contains( + "At least one device group should be specified in '' element."); } @Test - public void moduleConditions_wrongElementInsideDeviceTiersCondition_throws() { - XmlProtoElement badDeviceTierCondition = - XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "device-tiers") + public void moduleConditions_wrongElementInsideDeviceGroupsCondition_throws() { + XmlProtoElement badDeviceGroupCondition = + XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "device-groups") .addChildElement(XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "wrong")) .build(); Optional element = ManifestDeliveryElement.fromManifestRootNode( - createAndroidManifestWithConditions(badDeviceTierCondition), + createAndroidManifestWithConditions(badDeviceGroupCondition), /* isFastFollowAllowed= */ false); assertThat(element).isPresent(); @@ -438,24 +439,24 @@ public void moduleConditions_wrongElementInsideDeviceTiersCondition_throws() { assertThat(exception) .hasMessageThat() .contains( - "Expected only '' elements inside '', but found" + "Expected only '' elements inside '', but found" + " 'wrong'"); } @Test - public void moduleConditions_wrongAttributeInDeviceTierElement_throws() { - XmlProtoElement badDeviceTierCondition = - XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "device-tiers") + public void moduleConditions_wrongAttributeInDeviceGroupElement_throws() { + XmlProtoElement badDeviceGroupCondition = + XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "device-groups") .addChildElement( - XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "device-tier") + XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, "device-group") .addAttribute( - XmlProtoAttributeBuilder.create(DISTRIBUTION_NAMESPACE_URI, "tierName") - .setValueAsString("low"))) + XmlProtoAttributeBuilder.create(DISTRIBUTION_NAMESPACE_URI, "groupName") + .setValueAsString("group1"))) .build(); Optional element = ManifestDeliveryElement.fromManifestRootNode( - createAndroidManifestWithConditions(badDeviceTierCondition), + createAndroidManifestWithConditions(badDeviceGroupCondition), /* isFastFollowAllowed= */ false); assertThat(element).isPresent(); @@ -465,15 +466,16 @@ public void moduleConditions_wrongAttributeInDeviceTierElement_throws() { assertThat(exception) .hasMessageThat() .isEqualTo( - "'' element is expected to have 'dist:name' attribute but found" + "'' element is expected to have 'dist:name' attribute but found" + " none."); } @Test - public void moduleConditions_wrongDeviceTierName_throws() { + public void moduleConditions_wrongDeviceGroupName_throws() { Optional element = ManifestDeliveryElement.fromManifestRootNode( - androidManifest("com.test.app", withDeviceTiersCondition(ImmutableList.of("tier!!!"))), + androidManifest( + "com.test.app", withDeviceGroupsCondition(ImmutableList.of("group!!!"))), /* isFastFollowAllowed= */ false); assertThat(element).isPresent(); @@ -483,9 +485,9 @@ public void moduleConditions_wrongDeviceTierName_throws() { assertThat(exception) .hasMessageThat() .isEqualTo( - "Device tier names should start with a letter and contain only " - + "letters, numbers and underscores. Found tier named 'tier!!!' in " - + "'' element."); + "Device group names should start with a letter and contain only " + + "letters, numbers and underscores. Found group named 'group!!!' in " + + "'' element."); } @Test diff --git a/src/test/java/com/android/tools/build/bundletool/model/ModuleConditionsTest.java b/src/test/java/com/android/tools/build/bundletool/model/ModuleConditionsTest.java index 7dac3596..19881e44 100644 --- a/src/test/java/com/android/tools/build/bundletool/model/ModuleConditionsTest.java +++ b/src/test/java/com/android/tools/build/bundletool/model/ModuleConditionsTest.java @@ -17,7 +17,7 @@ package com.android.tools.build.bundletool.model; import static com.android.tools.build.bundletool.testing.TargetingUtils.mergeModuleTargeting; -import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceTiersTargeting; +import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleDeviceGroupsTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleExcludeCountriesTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleFeatureTargeting; import static com.android.tools.build.bundletool.testing.TargetingUtils.moduleIncludeCountriesTargeting; @@ -121,16 +121,17 @@ public void toTargeting_deviceFeatureVersionedConditions() { } @Test - public void toTargeting_deviceTiersConditions() { + public void toTargeting_deviceGroupsConditions() { ModuleConditions moduleConditions = ModuleConditions.builder() - .setDeviceTiersCondition(DeviceTiersCondition.create(ImmutableSet.of("mid", "high"))) + .setDeviceGroupsCondition( + DeviceGroupsCondition.create(ImmutableSet.of("group1", "group2"))) .build(); ModuleTargeting moduleTargeting = moduleConditions.toTargeting(); assertThat(moduleTargeting) .ignoringRepeatedFieldOrder() - .isEqualTo(moduleDeviceTiersTargeting("mid", "high")); + .isEqualTo(moduleDeviceGroupsTargeting("group1", "group2")); } @Test @@ -172,7 +173,7 @@ public void toTargeting_mixedConditions() { .setMinSdkVersion(24) .setUserCountriesCondition( UserCountriesCondition.create(ImmutableList.of("FR"), /* exclude= */ false)) - .setDeviceTiersCondition(DeviceTiersCondition.create(ImmutableSet.of("high"))) + .setDeviceGroupsCondition(DeviceGroupsCondition.create(ImmutableSet.of("group1"))) .build(); ModuleTargeting moduleTargeting = moduleConditions.toTargeting(); @@ -184,6 +185,6 @@ public void toTargeting_mixedConditions() { moduleFeatureTargeting("com.feature2"), moduleMinSdkVersionTargeting(24), moduleIncludeCountriesTargeting("FR"), - moduleDeviceTiersTargeting("high"))); + moduleDeviceGroupsTargeting("group1"))); } } diff --git a/src/test/java/com/android/tools/build/bundletool/shards/StandaloneApksGeneratorTest.java b/src/test/java/com/android/tools/build/bundletool/shards/StandaloneApksGeneratorTest.java index 2d74dec1..7db6143b 100644 --- a/src/test/java/com/android/tools/build/bundletool/shards/StandaloneApksGeneratorTest.java +++ b/src/test/java/com/android/tools/build/bundletool/shards/StandaloneApksGeneratorTest.java @@ -74,6 +74,8 @@ import com.android.bundle.Config.SplitDimension; import com.android.bundle.Config.SplitDimension.Value; import com.android.bundle.Config.SplitsConfig; +import com.android.bundle.Config.StandaloneConfig; +import com.android.bundle.Config.StandaloneConfig.DexMergingStrategy; import com.android.bundle.Config.SuffixStripping; import com.android.bundle.Targeting.Abi.AbiAlias; import com.android.bundle.Targeting.ApkTargeting; @@ -158,6 +160,14 @@ public class StandaloneApksGeneratorTest { ByteSource.empty()) .build(); + private static final BundleConfig.Builder bundleConfigNodexmergestrategy = + BundleConfig.newBuilder() + .setOptimizations( + Optimizations.newBuilder() + .setStandaloneConfig( + StandaloneConfig.newBuilder() + .setDexMergingStrategy(DexMergingStrategy.NEVER_MERGE))); + @Inject StandaloneApksGenerator standaloneApksGenerator; @Before @@ -479,7 +489,11 @@ public void shardByAbi_havingManyAbis_producesManyApks() throws Exception { @Test public void shardByAbi_havingManyAbis_producesManyApks_withTransparency() throws Exception { TestComponent.useTestModule( - this, TestModule.builder().withBundleMetadata(BUNDLE_METADATA_WITH_TRANSPARENCY).build()); + this, + TestModule.builder() + .withBundleConfig(bundleConfigNodexmergestrategy) + .withBundleMetadata(BUNDLE_METADATA_WITH_TRANSPARENCY) + .build()); BundleModule bundleModule = new BundleModuleBuilder("base") .addFile("assets/file.txt") @@ -818,7 +832,11 @@ public void shardByAbiAndDensity_havingOneAbiAndSomeDensityResource_producesMany public void shardByAbiAndDensity_havingOneAbiAndSomeDensityResource_produceManyApks_transparency() throws Exception { TestComponent.useTestModule( - this, TestModule.builder().withBundleMetadata(BUNDLE_METADATA_WITH_TRANSPARENCY).build()); + this, + TestModule.builder() + .withBundleConfig(bundleConfigNodexmergestrategy) + .withBundleMetadata(BUNDLE_METADATA_WITH_TRANSPARENCY) + .build()); BundleModule bundleModule = new BundleModuleBuilder("base") .addFile("assets/file.txt") @@ -1074,7 +1092,11 @@ public void manyModulesShardByNoDimension_producesFatApk() throws Exception { @Test public void manyModulesShardByNoDimension_producesFatApk_withTransparency() throws Exception { TestComponent.useTestModule( - this, TestModule.builder().withBundleMetadata(BUNDLE_METADATA_WITH_TRANSPARENCY).build()); + this, + TestModule.builder() + .withBundleConfig(bundleConfigNodexmergestrategy) + .withBundleMetadata(BUNDLE_METADATA_WITH_TRANSPARENCY) + .build()); BundleModule baseModule = new BundleModuleBuilder("base") .addFile("lib/x86_64/libtest1.so") diff --git a/src/test/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjectorTest.java b/src/test/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjectorTest.java new file mode 100644 index 00000000..c2c903f2 --- /dev/null +++ b/src/test/java/com/android/tools/build/bundletool/splitters/CodeTransparencyInjectorTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.splitters; + +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifest; +import static com.android.tools.build.bundletool.testing.ManifestProtoUtils.androidManifestForFeature; +import static com.google.common.truth.Truth8.assertThat; + +import com.android.bundle.Targeting.ApkTargeting; +import com.android.bundle.Targeting.VariantTargeting; +import com.android.tools.build.bundletool.model.AndroidManifest; +import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.BundleModuleName; +import com.android.tools.build.bundletool.model.ModuleEntry; +import com.android.tools.build.bundletool.model.ModuleSplit; +import com.android.tools.build.bundletool.model.ModuleSplit.SplitType; +import com.android.tools.build.bundletool.testing.AppBundleBuilder; +import com.android.tools.build.bundletool.testing.BundleModuleBuilder; +import com.google.common.io.ByteSource; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CodeTransparencyInjectorTest { + + @Test + public void mainSplitOfTheBaseModule_transparencyFileInjected() { + CodeTransparencyInjector injector = + new CodeTransparencyInjector(getAppBundleBuilder(/* minSdkVersion= */ 28).build()); + ModuleSplit moduleSplit = + getModuleSplitBuilder() + .setModuleName(BundleModuleName.BASE_MODULE_NAME) + .setMasterSplit(true) + .setSplitType(SplitType.SPLIT) + .build(); + + ModuleSplit result = injector.inject(moduleSplit); + + assertThat(getTransparencyFile(result)).isPresent(); + } + + @Test + public void notMainSplit_transparencyFileNotInjected() { + CodeTransparencyInjector injector = + new CodeTransparencyInjector(getAppBundleBuilder(/* minSdkVersion= */ 28).build()); + ModuleSplit moduleSplit = + getModuleSplitBuilder() + .setModuleName(BundleModuleName.BASE_MODULE_NAME) + .setMasterSplit(false) + .setSplitType(SplitType.SPLIT) + .build(); + + ModuleSplit result = injector.inject(moduleSplit); + + assertThat(getTransparencyFile(result)).isEmpty(); + } + + @Test + public void notBaseModule_transparencyFileNotInjected() { + CodeTransparencyInjector injector = + new CodeTransparencyInjector(getAppBundleBuilder(/* minSdkVersion= */ 28).build()); + ModuleSplit moduleSplit = + getModuleSplitBuilder() + .setModuleName(BundleModuleName.create("config_split")) + .setMasterSplit(true) + .setSplitType(SplitType.SPLIT) + .build(); + + ModuleSplit result = injector.inject(moduleSplit); + + assertThat(getTransparencyFile(result)).isEmpty(); + } + + @Test + public void standalone_withDfms_minSdkGreatedThan21_transparencyFileInjected() { + CodeTransparencyInjector injector = + new CodeTransparencyInjector( + getAppBundleBuilder(/* minSdkVersion= */ 28) + .addModule( + new BundleModuleBuilder("DFM") + .setManifest(androidManifestForFeature("com.test.app")) + .build()) + .build()); + ModuleSplit moduleSplit = + getModuleSplitBuilder() + .setModuleName(BundleModuleName.BASE_MODULE_NAME) + .setSplitType(SplitType.STANDALONE) + .setMasterSplit(false) + .build(); + + ModuleSplit result = injector.inject(moduleSplit); + + assertThat(getTransparencyFile(result)).isPresent(); + } + + @Test + public void standalone_noDfms_minSdk20_transparencyFileInjected() { + // Setting min SDK version < 21 would prevent transparency file propagation if + // the bundle had any DFMs. + CodeTransparencyInjector injector = + new CodeTransparencyInjector(getAppBundleBuilder(/* minSdkVersion= */ 20).build()); + ModuleSplit moduleSplit = + getModuleSplitBuilder() + .setModuleName(BundleModuleName.BASE_MODULE_NAME) + .setSplitType(SplitType.STANDALONE) + .setMasterSplit(false) + .build(); + + ModuleSplit result = injector.inject(moduleSplit); + + assertThat(getTransparencyFile(result)).isPresent(); + } + + @Test + public void standalone_withDfms_minSdk20_transparencyFileNotInjected() { + // Setting min SDK version < 21 should prevent transparency file propagation for + // this bundle, since it has DFM and specifies default + // DexMergingStrategy.MERGE_IF_NEEDED. + CodeTransparencyInjector injector = + new CodeTransparencyInjector( + getAppBundleBuilder(/* minSdkVersion= */ 20) + .addModule( + new BundleModuleBuilder("DFM") + .setManifest(androidManifestForFeature("com.test.app")) + .build()) + .build()); + ModuleSplit moduleSplit = + getModuleSplitBuilder() + .setModuleName(BundleModuleName.BASE_MODULE_NAME) + .setSplitType(SplitType.STANDALONE) + .setMasterSplit(false) + .build(); + + ModuleSplit result = injector.inject(moduleSplit); + + assertThat(getTransparencyFile(result)).isEmpty(); + } + + private static Optional getTransparencyFile(ModuleSplit moduleSplit) { + return moduleSplit.getEntries().stream() + .filter( + moduleEntry -> + moduleEntry + .getPath() + .getFileName() + .toString() + .equals(BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME)) + .findFirst(); + } + + private static ModuleSplit.Builder getModuleSplitBuilder() { + return ModuleSplit.builder() + .setApkTargeting(ApkTargeting.getDefaultInstance()) + .setAndroidManifest( + AndroidManifest.create(androidManifest("com.test.app")) + .toEditor() + .setMinSdkVersion(28) + .save()) + .setVariantTargeting(VariantTargeting.getDefaultInstance()); + } + + private static AppBundleBuilder getAppBundleBuilder(int minSdkVersion) { + return new AppBundleBuilder() + .addMetadataFile( + BundleMetadata.BUNDLETOOL_NAMESPACE, + BundleMetadata.TRANSPARENCY_SIGNED_FILE_NAME, + ByteSource.empty()) + .addModule( + new BundleModuleBuilder("base") + .setManifest( + AndroidManifest.create(androidManifest("com.test.app")) + .toEditor() + .setMinSdkVersion(minSdkVersion) + .save() + .getManifestRoot() + .getProto()) + .build()); + } +} diff --git a/src/test/java/com/android/tools/build/bundletool/splitters/ModuleSplitterTest.java b/src/test/java/com/android/tools/build/bundletool/splitters/ModuleSplitterTest.java index 77e3b840..33f0d35c 100644 --- a/src/test/java/com/android/tools/build/bundletool/splitters/ModuleSplitterTest.java +++ b/src/test/java/com/android/tools/build/bundletool/splitters/ModuleSplitterTest.java @@ -107,7 +107,7 @@ import com.android.bundle.Targeting.ScreenDensity.DensityAlias; import com.android.bundle.Targeting.VariantTargeting; import com.android.tools.build.bundletool.model.AndroidManifest; -import com.android.tools.build.bundletool.model.BundleMetadata; +import com.android.tools.build.bundletool.model.AppBundle; import com.android.tools.build.bundletool.model.BundleModule; import com.android.tools.build.bundletool.model.BundleModuleName; import com.android.tools.build.bundletool.model.ModuleEntry; @@ -122,6 +122,7 @@ import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoElement; import com.android.tools.build.bundletool.model.version.BundleToolVersion; import com.android.tools.build.bundletool.model.version.Version; +import com.android.tools.build.bundletool.testing.AppBundleBuilder; import com.android.tools.build.bundletool.testing.BundleModuleBuilder; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -146,7 +147,7 @@ public class ModuleSplitterTest { private static final Version BUNDLETOOL_VERSION = BundleToolVersion.getCurrentVersion(); - private static final BundleMetadata BUNDLE_METADATA = BundleMetadata.builder().build(); + private static final AppBundle APP_BUNDLE = new AppBundleBuilder().build(); @Test public void minSdkVersionInOutputTargeting_getsSetToL() throws Exception { @@ -174,7 +175,7 @@ public void rPlusSigningConfigWithRPlusVariant_minSdkVersionInOutputTargetingGet ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setMinimumV3SigningApiVersion(Optional.of(ANDROID_R_API_VERSION)) .build(), @@ -196,7 +197,7 @@ public void rPlusSigningConfigWithDefaultVariant_minSdkVersionInOutputTargetingN ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setMinimumV3SigningApiVersion(Optional.of(ANDROID_R_API_VERSION)) .build(), @@ -218,7 +219,7 @@ public void defaultSigningConfigWithRPlusVariant_minSdkVersionInOutputTargetingN ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.getDefaultInstance(), variantMinSdkTargeting(Versions.ANDROID_R_API_VERSION), ImmutableSet.of("testModule")) @@ -815,7 +816,7 @@ public void nativeSplits_lPlusTargeting_withAbiAndUncompressNativeLibsSplitter() ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(ABI)) .setEnableUncompressedNativeLibraries(true) @@ -854,7 +855,7 @@ public void nativeSplits_mPlusTargeting_withAbiAndUncompressNativeLibsSplitter() ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(ABI)) .setEnableUncompressedNativeLibraries(true) @@ -892,7 +893,7 @@ public void nativeSplits_mPlusTargeting_disabledUncompressedSplitter() { ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(ABI)) .setEnableUncompressedNativeLibraries(false) @@ -1068,7 +1069,7 @@ public void nativeSplits_pPlusTargeting_withDexCompressionSplitter() throws Exce ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setEnableDexCompressionSplitter(true).build(), variantMinSdkTargeting(ANDROID_Q_API_VERSION), ImmutableSet.of("testModule")); @@ -1094,7 +1095,7 @@ public void nativeSplits_lPlusTargeting_withDexCompressionSplitter() throws Exce ModuleSplitter.createNoStamp( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setEnableDexCompressionSplitter(true).build(), lPlusVariantTargeting(), ImmutableSet.of("testModule")); @@ -1448,7 +1449,7 @@ public void splitNameNotRemovedForInstantSplit() throws Exception { ModuleSplitter.createNoStamp( bundleModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setForInstantAppVariants(true).build(), lPlusVariantTargeting(), ImmutableSet.of("testModule")) @@ -1509,7 +1510,7 @@ public void nonInstantActivityRemovedForInstantManifest() throws Exception { ModuleSplitter.createNoStamp( bundleModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setForInstantAppVariants(true).build(), lPlusVariantTargeting(), ImmutableSet.of()) @@ -1544,7 +1545,7 @@ public void instantManifestChanges_addsMinSdkVersion() throws Exception { ModuleSplitter.createNoStamp( bundleModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setForInstantAppVariants(true).build(), lPlusVariantTargeting(), ImmutableSet.of("testModule")) @@ -1571,7 +1572,7 @@ public void instantManifestChanges_keepsMinSdkVersion() throws Exception { ModuleSplitter.createNoStamp( bundleModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setForInstantAppVariants(true).build(), lPlusVariantTargeting(), ImmutableSet.of("testModule")) @@ -1598,7 +1599,7 @@ public void instantManifestChanges_updatesMinSdkVersion() throws Exception { ModuleSplitter.createNoStamp( bundleModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder().setForInstantAppVariants(true).build(), lPlusVariantTargeting(), ImmutableSet.of("testModule")) @@ -1677,7 +1678,7 @@ public void addingLibraryPlaceholders_baseModule() throws Exception { ModuleSplitter.createNoStamp( baseModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setAbisForPlaceholderLibs( ImmutableSet.of(toAbi(AbiAlias.X86), toAbi(AbiAlias.ARM64_V8A))) @@ -1706,7 +1707,7 @@ public void addingLibraryPlaceholders_featureModule_noAction() throws Exception ModuleSplitter.createNoStamp( baseModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setAbisForPlaceholderLibs( ImmutableSet.of(toAbi(AbiAlias.X86), toAbi(AbiAlias.ARM64_V8A))) @@ -1751,7 +1752,7 @@ public void wholeResourcePinning_allConfigsInMaster() throws Exception { ModuleSplitter.createNoStamp( baseModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(LANGUAGE)) .setMasterPinnedResourceIds(ImmutableSet.of(ResourceId.create(0x7f010001))) @@ -1813,7 +1814,7 @@ public void wholeResourcePinning_langResourcePinnedByName() throws Exception { ModuleSplitter.createNoStamp( baseModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(LANGUAGE)) .setMasterPinnedResourceNames(ImmutableSet.of("welcome_label")) @@ -1871,7 +1872,7 @@ public void manifestResourcePinning_langResourceWithDefaultConfig_notPinned() th ModuleSplitter.createNoStamp( baseModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(LANGUAGE)) .setBaseManifestReachableResources(ImmutableSet.of(ResourceId.create(0x7f010001))) @@ -1927,7 +1928,7 @@ public void manifestResourcePinning_langResourceWithoutDefaultConfig_pinned() th ModuleSplitter.createNoStamp( baseModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(LANGUAGE)) .setBaseManifestReachableResources(ImmutableSet.of(ResourceId.create(0x7f010001))) @@ -1961,7 +1962,7 @@ public void testModuleSplitter_baseSplit_addsStamp() throws Exception { ModuleSplitter.create( bundleModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.getDefaultInstance(), lPlusVariantTargeting(), ImmutableSet.of("base"), @@ -1995,7 +1996,7 @@ public void testModuleSplitter_nativeSplit_addsNoStamp() throws Exception { ModuleSplitter.create( testModule, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, ApkGenerationConfiguration.builder() .setOptimizationDimensions(ImmutableSet.of(ABI)) .setEnableUncompressedNativeLibraries(true) @@ -2059,7 +2060,7 @@ private static ModuleSplitter createTextureCompressionFormatSplitter(BundleModul return ModuleSplitter.createNoStamp( module, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, withTcfSuffixStripping( withOptimizationDimensions(ImmutableSet.of(TEXTURE_COMPRESSION_FORMAT))), lPlusVariantTargeting(), @@ -2070,7 +2071,7 @@ private static ModuleSplitter createDeviceTierSplitter(BundleModule module) { return ModuleSplitter.createNoStamp( module, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, withOptimizationDimensions(ImmutableSet.of(DEVICE_TIER)), lPlusVariantTargeting(), ImmutableSet.of(module.getName().getName())); @@ -2080,7 +2081,7 @@ private static ModuleSplitter createAbiAndDensitySplitter(BundleModule module) { return ModuleSplitter.createNoStamp( module, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, withOptimizationDimensions(ImmutableSet.of(ABI, SCREEN_DENSITY)), lPlusVariantTargeting(), ImmutableSet.of(module.getName().getName())); @@ -2090,7 +2091,7 @@ private static ModuleSplitter createAbiDensityAndLanguageSplitter(BundleModule m return ModuleSplitter.createNoStamp( module, BUNDLETOOL_VERSION, - BUNDLE_METADATA, + APP_BUNDLE, withOptimizationDimensions(ImmutableSet.of(ABI, SCREEN_DENSITY, LANGUAGE)), lPlusVariantTargeting(), ImmutableSet.of(module.getName().getName())); diff --git a/src/test/java/com/android/tools/build/bundletool/testing/BundleModuleBuilder.java b/src/test/java/com/android/tools/build/bundletool/testing/BundleModuleBuilder.java index 7e2a0d9f..b12cdabd 100644 --- a/src/test/java/com/android/tools/build/bundletool/testing/BundleModuleBuilder.java +++ b/src/test/java/com/android/tools/build/bundletool/testing/BundleModuleBuilder.java @@ -85,6 +85,25 @@ public BundleModuleBuilder addFile( return this; } + /** + * Adds an entry that should be sourced from a zip file. + * + * @param relativePath the file path in the module that is being constructed + * @param zipFilePath location of the on-disk zip file + * @param entryFullZipPath the entry path inside the zip file + * @param content the contents of the file + */ + public BundleModuleBuilder addFile( + String relativePath, Path zipFilePath, ZipPath entryFullZipPath, byte[] content) { + entries.add( + ModuleEntry.builder() + .setContent(ByteSource.wrap(content)) + .setPath(ZipPath.create(relativePath)) + .setBundleLocation(ModuleEntryBundleLocation.create(zipFilePath, entryFullZipPath)) + .build()); + return this; + } + public BundleModuleBuilder addFile(String relativePath) { return addFile(relativePath, new byte[1]); } diff --git a/src/test/java/com/android/tools/build/bundletool/testing/CodeTransparencyTestUtils.java b/src/test/java/com/android/tools/build/bundletool/testing/CodeTransparencyTestUtils.java new file mode 100644 index 00000000..f5f0a9d4 --- /dev/null +++ b/src/test/java/com/android/tools/build/bundletool/testing/CodeTransparencyTestUtils.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.tools.build.bundletool.testing; + +import static org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256; + +import com.android.bundle.CodeTransparencyOuterClass.CodeTransparency; +import com.google.protobuf.util.JsonFormat; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import org.jose4j.jws.JsonWebSignature; + +/** Helper class for writing code transparency related tests. */ +public final class CodeTransparencyTestUtils { + + public static String createJwsToken( + CodeTransparency codeTransparency, X509Certificate certificate, PrivateKey privateKey) + throws Exception { + return createJwsToken(codeTransparency, certificate, privateKey, RSA_USING_SHA256); + } + + public static String createJwsToken( + CodeTransparency codeTransparency, + X509Certificate certificate, + PrivateKey privateKey, + String algorithmIdentifier) + throws Exception { + JsonWebSignature jws = new JsonWebSignature(); + jws.setAlgorithmHeaderValue(algorithmIdentifier); + jws.setCertificateChainHeaderValue(certificate); + jws.setPayload(JsonFormat.printer().print(codeTransparency)); + jws.setKey(privateKey); + return jws.getCompactSerialization(); + } + + private CodeTransparencyTestUtils() {} +} diff --git a/src/test/java/com/android/tools/build/bundletool/testing/DeviceFactory.java b/src/test/java/com/android/tools/build/bundletool/testing/DeviceFactory.java index 2f9f6443..44e7ef26 100644 --- a/src/test/java/com/android/tools/build/bundletool/testing/DeviceFactory.java +++ b/src/test/java/com/android/tools/build/bundletool/testing/DeviceFactory.java @@ -141,6 +141,10 @@ public static DeviceSpec deviceTier(String deviceTier) { return DeviceSpec.newBuilder().setDeviceTier(deviceTier).build(); } + public static DeviceSpec deviceGroups(String... deviceGroups) { + return DeviceSpec.newBuilder().addAllDeviceGroups(Arrays.asList(deviceGroups)).build(); + } + public static DeviceSpec mergeSpecs(DeviceSpec deviceSpec, DeviceSpec... specParts) { return mergeFromProtos(deviceSpec, specParts); } diff --git a/src/test/java/com/android/tools/build/bundletool/testing/FakeDevice.java b/src/test/java/com/android/tools/build/bundletool/testing/FakeDevice.java index 1c733a62..3c0139c5 100644 --- a/src/test/java/com/android/tools/build/bundletool/testing/FakeDevice.java +++ b/src/test/java/com/android/tools/build/bundletool/testing/FakeDevice.java @@ -34,7 +34,11 @@ import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteStreams; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -256,6 +260,19 @@ public Path syncPackageToDevice(Path localFilePath) { @Override public void removeRemotePackage(Path remoteFilePath) {} + @Override + public void pull(ImmutableList files) { + for (FilePullParams filePullParams : files) { + Path sourcePath = Paths.get(filePullParams.getPathOnDevice()); + try (InputStream inputStream = Files.newInputStream(sourcePath); + OutputStream outputStream = Files.newOutputStream(filePullParams.getDestinationPath())) { + ByteStreams.copy(inputStream, outputStream); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + public void setInstallApksSideEffect(SideEffect sideEffect) { installApksSideEffect = Optional.of(sideEffect); } diff --git a/src/test/java/com/android/tools/build/bundletool/testing/ManifestProtoUtils.java b/src/test/java/com/android/tools/build/bundletool/testing/ManifestProtoUtils.java index 6fc28779..06e4313f 100644 --- a/src/test/java/com/android/tools/build/bundletool/testing/ManifestProtoUtils.java +++ b/src/test/java/com/android/tools/build/bundletool/testing/ManifestProtoUtils.java @@ -19,10 +19,10 @@ import static com.android.tools.build.bundletool.model.AndroidManifest.ACTIVITY_ELEMENT_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.ANDROID_NAMESPACE_URI; import static com.android.tools.build.bundletool.model.AndroidManifest.APPLICATION_ELEMENT_NAME; -import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_DEVICE_TIERS_NAME; +import static com.android.tools.build.bundletool.model.AndroidManifest.CONDITION_DEVICE_GROUPS_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.DEBUGGABLE_ATTRIBUTE_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.DEBUGGABLE_RESOURCE_ID; -import static com.android.tools.build.bundletool.model.AndroidManifest.DEVICE_TIER_ELEMENT_NAME; +import static com.android.tools.build.bundletool.model.AndroidManifest.DEVICE_GROUP_ELEMENT_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.DISTRIBUTION_NAMESPACE_URI; import static com.android.tools.build.bundletool.model.AndroidManifest.EXTRACT_NATIVE_LIBS_ATTRIBUTE_NAME; import static com.android.tools.build.bundletool.model.AndroidManifest.EXTRACT_NATIVE_LIBS_RESOURCE_ID; @@ -293,6 +293,18 @@ public static XmlNode androidManifestForFeature( ManifestMutator.class)); } + public static XmlNode androidManifestForMlModule( + String packageName, ManifestMutator... manifestMutators) { + return androidManifest( + packageName, + ObjectArrays.concat( + new ManifestMutator[] { + withOnDemandAttribute(true), withFusingAttribute(true), withTypeAttribute("ml-pack") + }, + manifestMutators, + ManifestMutator.class)); + } + public static XmlNode androidManifestForAssetModule( String packageName, ManifestMutator... manifestMutators) { XmlNode manifestNode = @@ -748,16 +760,16 @@ public static ManifestMutator withMaxSdkCondition(int maxSdkVersion) { .setValueAsDecimalInteger(maxSdkVersion))); } - public static ManifestMutator withDeviceTiersCondition(ImmutableList deviceTiers) { - XmlProtoElementBuilder deviceTiersElement = - XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, CONDITION_DEVICE_TIERS_NAME); + public static ManifestMutator withDeviceGroupsCondition(ImmutableList deviceGroups) { + XmlProtoElementBuilder deviceGroupsElement = + XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, CONDITION_DEVICE_GROUPS_NAME); - for (String deviceTier : deviceTiers) { - deviceTiersElement.addChildElement( - XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, DEVICE_TIER_ELEMENT_NAME) + for (String deviceGroup : deviceGroups) { + deviceGroupsElement.addChildElement( + XmlProtoElementBuilder.create(DISTRIBUTION_NAMESPACE_URI, DEVICE_GROUP_ELEMENT_NAME) .addAttribute( XmlProtoAttributeBuilder.create(DISTRIBUTION_NAMESPACE_URI, NAME_ATTRIBUTE_NAME) - .setValueAsString(deviceTier))); + .setValueAsString(deviceGroup))); } return manifestElement -> @@ -766,7 +778,7 @@ public static ManifestMutator withDeviceTiersCondition(ImmutableList dev .getOrCreateChildElement(DISTRIBUTION_NAMESPACE_URI, "delivery") .getOrCreateChildElement(DISTRIBUTION_NAMESPACE_URI, "install-time") .getOrCreateChildElement(DISTRIBUTION_NAMESPACE_URI, "conditions") - .addChildElement(deviceTiersElement); + .addChildElement(deviceGroupsElement); } /** diff --git a/src/test/java/com/android/tools/build/bundletool/testing/TargetingUtils.java b/src/test/java/com/android/tools/build/bundletool/testing/TargetingUtils.java index d699dd10..b396bb1a 100644 --- a/src/test/java/com/android/tools/build/bundletool/testing/TargetingUtils.java +++ b/src/test/java/com/android/tools/build/bundletool/testing/TargetingUtils.java @@ -36,7 +36,7 @@ import com.android.bundle.Targeting.AssetsDirectoryTargeting; import com.android.bundle.Targeting.DeviceFeature; import com.android.bundle.Targeting.DeviceFeatureTargeting; -import com.android.bundle.Targeting.DeviceTierModuleTargeting; +import com.android.bundle.Targeting.DeviceGroupModuleTargeting; import com.android.bundle.Targeting.DeviceTierTargeting; import com.android.bundle.Targeting.LanguageTargeting; import com.android.bundle.Targeting.ModuleTargeting; @@ -591,10 +591,10 @@ public static ModuleTargeting moduleMinMaxSdkVersionTargeting( .build(); } - public static ModuleTargeting moduleDeviceTiersTargeting(String... deviceTiers) { + public static ModuleTargeting moduleDeviceGroupsTargeting(String... deviceTiers) { return ModuleTargeting.newBuilder() - .setDeviceTierTargeting( - DeviceTierModuleTargeting.newBuilder().addAllValue(Arrays.asList(deviceTiers))) + .setDeviceGroupTargeting( + DeviceGroupModuleTargeting.newBuilder().addAllValue(Arrays.asList(deviceTiers))) .build(); } diff --git a/src/test/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidatorTest.java b/src/test/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidatorTest.java index 7f69a6dc..6d388b4b 100644 --- a/src/test/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidatorTest.java +++ b/src/test/java/com/android/tools/build/bundletool/validation/CodeTransparencyValidatorTest.java @@ -127,7 +127,7 @@ public void transparencyVerificationFailed_invalidSignature() throws Exception { () -> new CodeTransparencyValidator().validateBundle(bundle)); assertThat(e) .hasMessageThat() - .contains("Code transparency verification failed because signature is invalid."); + .contains("Verification failed because code transparency signature is invalid."); } @Test @@ -142,8 +142,8 @@ public void transparencyVerificationFailed_codeModified() throws Exception { assertThat(e) .hasMessageThat() .contains( - "Code transparency verification failed because code was modified after transparency" - + " metadata generation."); + "Verification failed because code was modified after transparency metadata" + + " generation."); assertThat(e) .hasMessageThat() .contains( diff --git a/src/test/resources/com/android/tools/build/bundletool/testdata/dex/classes-emulated-coredesugar.dex b/src/test/resources/com/android/tools/build/bundletool/testdata/dex/classes-emulated-coredesugar.dex new file mode 100644 index 0000000000000000000000000000000000000000..5ef8d9aaf98ec3759c4472f1ff99b921d2f30fc1 GIT binary patch literal 1172 zcmZXU&u7i^z6y+_pwWfec2B)J8#~+d2eUfc84?NrOWd7-JW^j zUgFD@>yN*l{qeE$?ep*3ub-FKUQUU0MPyjJl!egarztWIjrJt?4x%8fMDWNeex@Ne zAPvY@knf@FLy`mV3mC9PKnx^72Al_1!5Z+v3-AW)fe+vS`~kDbp};)AX>bl)1WRBU z+yYgAN9PiQjDs1F0L=RsNP*)Z4R}BtR|6rDK<^?5&&Rn{=#-4ZaRM-21NK~?Z$LM( zYaZ_kQE*hC^j#QOUBJ5mkCR%8%t1zBIRzOD>|B#UQp|aBd_rsvT!APg_5b{SFpm=z zx81rk@cPAi$G_=pH6%B-^txWN@A^)^SjldAn6Cc}{z$RHetJ=N1<$ z6-%;5?3T>aHt09K(hava*m3+y$Mf8+ezVi=Su#7Gy?eNwC39nQebCmvr3(B94_x1p zwXYB9}JCc~Z)6w@~jqp3gf*N2w@w>7X!(khF zNllrBXjVLxPoX-vLgb0AA(2h!tWh(lpJ%W$uAgSbBqggJE<{khNy)&9wej6)6@Sb2 zGmWP)Q2~s>!TmWX!ye|2VIQ-6!d_;b(~tek+{2#cJlC+VjfGJh_O(L2hCO0Jr_Ye* XfDd(^Nd@{YHwtuJ)9^