New integration test for fs-verity install and on-access verification

There are two categories of tests:

1. Package installation with .fsv_sig
  * .apk, .dm, including the split ones should all or none be installed
     with their corresponding .fsv_sig files

2. End-to-end fs-verity test of on-access verification
  * When fs-verity is enabled to a file, if the on-disk content is
    changed, the read should fail.

See class comment in ApkVerityTest.java for the test details.

Brief directory layout overview:
* src/
  - Actual test
* ApkVerityTestApp/
  - Dummy app for testing, including a split
* testdata/
  - Some artifacts, signing key and fs-verity signatures
* block_device_writer/
  - Helper binary for write a file directly on disk

Test: atest
Bug: 112039386
Change-Id: I3b8229037db682f36fda9d5cafd14caf6b39501d
diff --git a/tests/ApkVerityTest/Android.bp b/tests/ApkVerityTest/Android.bp
new file mode 100644
index 0000000..adcbb428
--- /dev/null
+++ b/tests/ApkVerityTest/Android.bp
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 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.
+
+java_test_host {
+    name: "ApkVerityTests",
+    srcs: ["src/**/*.java"],
+    libs: ["tradefed", "compatibility-tradefed", "compatibility-host-util"],
+    test_suites: ["general-tests"],
+    target_required: [
+        "block_device_writer_module",
+        "ApkVerityTestApp",
+        "ApkVerityTestAppSplit",
+    ],
+    data: [
+        ":ApkVerityTestCertDer",
+        ":ApkVerityTestAppFsvSig",
+        ":ApkVerityTestAppDm",
+        ":ApkVerityTestAppDmFsvSig",
+        ":ApkVerityTestAppSplitFsvSig",
+        ":ApkVerityTestAppSplitDm",
+        ":ApkVerityTestAppSplitDmFsvSig",
+    ],
+}
diff --git a/tests/ApkVerityTest/AndroidTest.xml b/tests/ApkVerityTest/AndroidTest.xml
new file mode 100644
index 0000000..73779cb
--- /dev/null
+++ b/tests/ApkVerityTest/AndroidTest.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 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.
+-->
+<configuration description="APK fs-verity integration/regression test">
+    <option name="test-suite-tag" value="apct" />
+
+    <!-- This test requires root to write against block device. -->
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+    <target_preparer class="com.android.tradefed.targetprep.DeviceSetup">
+        <!-- Disable package verifier prevents it holding the target APK's fd that prevents cache
+             eviction. -->
+        <option name="set-global-setting" key="package_verifier_enable" value="0" />
+        <option name="restore-settings" value="true" />
+
+        <!-- Skip in order to prevent reboot that confuses the test flow. -->
+        <option name="force-skip-system-props" value="true" />
+    </target_preparer>
+
+    <target_preparer class="com.android.tradefed.targetprep.PushFilePreparer">
+        <option name="cleanup" value="true" />
+        <option name="push" value="block_device_writer->/data/local/tmp/block_device_writer" />
+        <option name="push" value="ApkVerityTestCert.der->/data/local/tmp/ApkVerityTestCert.der" />
+    </target_preparer>
+
+    <test class="com.android.compatibility.common.tradefed.testtype.JarHostTest" >
+        <option name="jar" value="ApkVerityTests.jar" />
+    </test>
+</configuration>
diff --git a/tests/ApkVerityTest/ApkVerityTestApp/Android.bp b/tests/ApkVerityTest/ApkVerityTestApp/Android.bp
new file mode 100644
index 0000000..69632b2
--- /dev/null
+++ b/tests/ApkVerityTest/ApkVerityTestApp/Android.bp
@@ -0,0 +1,29 @@
+// Copyright (C) 2019 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.
+
+android_test_helper_app {
+  name: "ApkVerityTestApp",
+  manifest: "AndroidManifest.xml",
+  srcs: ["src/**/*.java"],
+}
+
+android_test_helper_app {
+  name: "ApkVerityTestAppSplit",
+  manifest: "feature_split/AndroidManifest.xml",
+  srcs: ["src/**/*.java"],
+  aaptflags: [
+      "--custom-package com.android.apkverity.feature_x",
+      "--package-id 0x80",
+  ],
+}
diff --git a/tests/ApkVerityTest/ApkVerityTestApp/AndroidManifest.xml b/tests/ApkVerityTest/ApkVerityTestApp/AndroidManifest.xml
new file mode 100644
index 0000000..0b3ff77
--- /dev/null
+++ b/tests/ApkVerityTest/ApkVerityTestApp/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  * Copyright (C) 2019 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.apkverity">
+    <application>
+        <activity android:name=".DummyActivity"/>
+    </application>
+</manifest>
diff --git a/tests/ApkVerityTest/ApkVerityTestApp/feature_split/AndroidManifest.xml b/tests/ApkVerityTest/ApkVerityTestApp/feature_split/AndroidManifest.xml
new file mode 100644
index 0000000..3f1a4f3
--- /dev/null
+++ b/tests/ApkVerityTest/ApkVerityTestApp/feature_split/AndroidManifest.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  * Copyright (C) 2019 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.apkverity"
+    android:isFeatureSplit="true"
+    split="feature_x">
+    <application>
+        <activity android:name=".feature_x.DummyActivity"/>
+    </application>
+</manifest>
diff --git a/tests/ApkVerityTest/ApkVerityTestApp/feature_split/src/com/android/apkverity/feature_x/DummyActivity.java b/tests/ApkVerityTest/ApkVerityTestApp/feature_split/src/com/android/apkverity/feature_x/DummyActivity.java
new file mode 100644
index 0000000..0f694c2
--- /dev/null
+++ b/tests/ApkVerityTest/ApkVerityTestApp/feature_split/src/com/android/apkverity/feature_x/DummyActivity.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2019 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.apkverity.feature_x;
+
+import android.app.Activity;
+
+/** Dummy class just to generate some dex */
+public class DummyActivity extends Activity {}
diff --git a/tests/ApkVerityTest/ApkVerityTestApp/src/com/android/apkverity/DummyActivity.java b/tests/ApkVerityTest/ApkVerityTestApp/src/com/android/apkverity/DummyActivity.java
new file mode 100644
index 0000000..837c7be
--- /dev/null
+++ b/tests/ApkVerityTest/ApkVerityTestApp/src/com/android/apkverity/DummyActivity.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2019 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.apkverity;
+
+import android.app.Activity;
+
+/** Dummy class just to generate some dex */
+public class DummyActivity extends Activity {}
diff --git a/tests/ApkVerityTest/block_device_writer/Android.bp b/tests/ApkVerityTest/block_device_writer/Android.bp
new file mode 100644
index 0000000..deed3a0
--- /dev/null
+++ b/tests/ApkVerityTest/block_device_writer/Android.bp
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 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.
+
+// This is a cc_test just because it supports test_suites. This should be converted to something
+// like cc_binary_test_helper once supported.
+cc_test {
+    // Depending on how the test runs, the executable may be uploaded to different location.
+    // Before the bug in the file pusher is fixed, workaround by making the name unique.
+    // See b/124718249#comment12.
+    name: "block_device_writer_module",
+    stem: "block_device_writer",
+
+    srcs: ["block_device_writer.cpp"],
+    cflags: ["-Wall", "-Werror", "-Wextra", "-g"],
+    shared_libs: ["libbase", "libutils"],
+
+    test_suites: ["general-tests"],
+    gtest: false,
+}
diff --git a/tests/ApkVerityTest/block_device_writer/block_device_writer.cpp b/tests/ApkVerityTest/block_device_writer/block_device_writer.cpp
new file mode 100644
index 0000000..b0c7251
--- /dev/null
+++ b/tests/ApkVerityTest/block_device_writer/block_device_writer.cpp
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+#include <cassert>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <memory>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/fiemap.h>
+#include <linux/fs.h>
+#include <sys/ioctl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include <android-base/unique_fd.h>
+
+// This program modifies a file at given offset, but directly against the block
+// device, purposely to bypass the filesystem. Note that the change on block
+// device may not reflect the same way when read from filesystem, for example,
+// when the file is encrypted on disk.
+//
+// Only one byte is supported for now just so that we don't need to handle the
+// case when the range crosses different "extents".
+//
+// References:
+//  https://www.kernel.org/doc/Documentation/filesystems/fiemap.txt
+//  https://git.kernel.org/pub/scm/fs/xfs/xfsprogs-dev.git/tree/io/fiemap.c
+
+ssize_t get_logical_block_size(const char* block_device) {
+  android::base::unique_fd fd(open(block_device, O_RDONLY));
+  if (fd.get() < 0) {
+    fprintf(stderr, "open %s failed\n", block_device);
+    return -1;
+  }
+
+  int size;
+  if (ioctl(fd, BLKSSZGET, &size) < 0) {
+    fprintf(stderr, "ioctl(BLKSSZGET) failed: %s\n", strerror(errno));
+    return -1;
+  }
+  return size;
+}
+
+int64_t get_physical_offset(const char* file_name, uint64_t byte_offset) {
+  android::base::unique_fd fd(open(file_name, O_RDONLY));
+  if (fd.get() < 0) {
+    fprintf(stderr, "open %s failed\n", file_name);
+    return -1;
+  }
+
+  const int map_size = sizeof(struct fiemap) + sizeof(struct fiemap_extent);
+  char fiemap_buffer[map_size] = {0};
+  struct fiemap* fiemap = reinterpret_cast<struct fiemap*>(&fiemap_buffer);
+
+  fiemap->fm_flags = FIEMAP_FLAG_SYNC;
+  fiemap->fm_start = byte_offset;
+  fiemap->fm_length = 1;
+  fiemap->fm_extent_count = 1;
+
+  int ret = ioctl(fd.get(), FS_IOC_FIEMAP, fiemap);
+  if (ret < 0) {
+    fprintf(stderr, "ioctl(FS_IOC_FIEMAP) failed: %s\n", strerror(errno));
+    return -1;
+  }
+
+  if (fiemap->fm_mapped_extents != 1) {
+    fprintf(stderr, "fm_mapped_extents != 1 (is %d)\n",
+            fiemap->fm_mapped_extents);
+    return -1;
+  }
+
+  struct fiemap_extent* extent = &fiemap->fm_extents[0];
+  printf(
+      "logical offset: %llu, physical offset: %llu, length: %llu, "
+      "flags: %x\n",
+      extent->fe_logical, extent->fe_physical, extent->fe_length,
+      extent->fe_flags);
+  if (extent->fe_flags & (FIEMAP_EXTENT_UNKNOWN |
+                          FIEMAP_EXTENT_UNWRITTEN)) {
+    fprintf(stderr, "Failed to locate physical offset safely\n");
+    return -1;
+  }
+
+  return extent->fe_physical + (byte_offset - extent->fe_logical);
+}
+
+int read_block_from_device(const char* device_path, uint64_t block_offset,
+                           ssize_t block_size, char* block_buffer) {
+  assert(block_offset % block_size == 0);
+  android::base::unique_fd fd(open(device_path, O_RDONLY | O_DIRECT));
+  if (fd.get() < 0) {
+    fprintf(stderr, "open %s failed\n", device_path);
+    return -1;
+  }
+
+  ssize_t retval =
+      TEMP_FAILURE_RETRY(pread(fd, block_buffer, block_size, block_offset));
+  if (retval != block_size) {
+    fprintf(stderr, "read returns error or incomplete result (%zu): %s\n",
+            retval, strerror(errno));
+    return -1;
+  }
+  return 0;
+}
+
+int write_block_to_device(const char* device_path, uint64_t block_offset,
+                          ssize_t block_size, char* block_buffer) {
+  assert(block_offset % block_size == 0);
+  android::base::unique_fd fd(open(device_path, O_WRONLY | O_DIRECT));
+  if (fd.get() < 0) {
+    fprintf(stderr, "open %s failed\n", device_path);
+    return -1;
+  }
+
+  ssize_t retval = TEMP_FAILURE_RETRY(
+      pwrite(fd.get(), block_buffer, block_size, block_offset));
+  if (retval != block_size) {
+    fprintf(stderr, "write returns error or incomplete result (%zu): %s\n",
+            retval, strerror(errno));
+    return -1;
+  }
+  return 0;
+}
+
+int main(int argc, const char** argv) {
+  if (argc != 4) {
+    fprintf(stderr,
+            "Usage: %s block_dev filename byte_offset\n"
+            "\n"
+            "This program bypasses filesystem and damages the specified byte\n"
+            "at the physical position on <block_dev> corresponding to the\n"
+            "logical byte location in <filename>.\n",
+            argv[0]);
+    return -1;
+  }
+
+  const char* block_device = argv[1];
+  const char* file_name = argv[2];
+  uint64_t byte_offset = strtoull(argv[3], nullptr, 10);
+
+  ssize_t block_size = get_logical_block_size(block_device);
+  if (block_size < 0) {
+    return -1;
+  }
+
+  int64_t physical_offset_signed = get_physical_offset(file_name, byte_offset);
+  if (physical_offset_signed < 0) {
+    return -1;
+  }
+
+  uint64_t physical_offset = static_cast<uint64_t>(physical_offset_signed);
+  uint64_t offset_within_block = physical_offset % block_size;
+  uint64_t physical_block_offset = physical_offset - offset_within_block;
+
+  // Direct I/O requires aligned buffer
+  std::unique_ptr<char> buf(static_cast<char*>(
+      aligned_alloc(block_size /* alignment */, block_size /* size */)));
+
+  if (read_block_from_device(block_device, physical_block_offset, block_size,
+                             buf.get()) < 0) {
+    return -1;
+  }
+  char* p = buf.get() + offset_within_block;
+  printf("before: %hhx\n", *p);
+  *p ^= 0xff;
+  printf("after: %hhx\n", *p);
+  if (write_block_to_device(block_device, physical_block_offset, block_size,
+                            buf.get()) < 0) {
+    return -1;
+  }
+
+  return 0;
+}
diff --git a/tests/ApkVerityTest/src/com/android/apkverity/ApkVerityTest.java b/tests/ApkVerityTest/src/com/android/apkverity/ApkVerityTest.java
new file mode 100644
index 0000000..761c5ce
--- /dev/null
+++ b/tests/ApkVerityTest/src/com/android/apkverity/ApkVerityTest.java
@@ -0,0 +1,496 @@
+/*
+ * Copyright (C) 2019 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.apkverity;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+
+import android.platform.test.annotations.RootPermissionTest;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.FileNotFoundException;
+import java.util.Arrays;
+import java.util.HashSet;
+
+/**
+ * This test makes sure app installs with fs-verity signature, and on-access verification works.
+ *
+ * <p>When an app is installed, all or none of the files should have their corresponding .fsv_sig
+ * signature file. Otherwise, install will fail.
+ *
+ * <p>Once installed, file protected by fs-verity is verified by kernel every time a block is loaded
+ * from disk to memory. The file is immutable by design, enforced by filesystem.
+ *
+ * <p>In order to make sure a block of the file is readable only if the underlying block on disk
+ * stay intact, the test needs to bypass the filesystem and tampers with the corresponding physical
+ * address against the block device.
+ *
+ * <p>Requirements to run this test:
+ * <ul>
+ *   <li>Device is rootable</li>
+ *   <li>The filesystem supports fs-verity</li>
+ *   <li>The feature flag is enabled</li>
+ * </ul>
+ */
+@RootPermissionTest
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class ApkVerityTest extends BaseHostJUnit4Test {
+    private static final String TARGET_PACKAGE = "com.android.apkverity";
+
+    private static final String BASE_APK = "ApkVerityTestApp.apk";
+    private static final String BASE_APK_DM = "ApkVerityTestApp.dm";
+    private static final String SPLIT_APK = "ApkVerityTestAppSplit.apk";
+    private static final String SPLIT_APK_DM = "ApkVerityTestAppSplit.dm";
+
+    private static final String INSTALLED_BASE_APK = "base.apk";
+    private static final String INSTALLED_BASE_DM = "base.dm";
+    private static final String INSTALLED_SPLIT_APK = "split_feature_x.apk";
+    private static final String INSTALLED_SPLIT_DM = "split_feature_x.dm";
+    private static final String INSTALLED_BASE_APK_FSV_SIG = "base.apk.fsv_sig";
+    private static final String INSTALLED_BASE_DM_FSV_SIG = "base.dm.fsv_sig";
+    private static final String INSTALLED_SPLIT_APK_FSV_SIG = "split_feature_x.apk.fsv_sig";
+    private static final String INSTALLED_SPLIT_DM_FSV_SIG = "split_feature_x.dm.fsv_sig";
+
+    private static final String DAMAGING_EXECUTABLE = "/data/local/tmp/block_device_writer";
+    private static final String CERT_PATH = "/data/local/tmp/ApkVerityTestCert.der";
+
+    private static final String APK_VERITY_STANDARD_MODE = "2";
+
+    /** Only 4K page is supported by fs-verity currently. */
+    private static final int FSVERITY_PAGE_SIZE = 4096;
+
+    private ITestDevice mDevice;
+    private String mKeyId;
+
+    @Before
+    public void setUp() throws DeviceNotAvailableException {
+        mDevice = getDevice();
+
+        String apkVerityMode = mDevice.getProperty("ro.apk_verity.mode");
+        assumeTrue(APK_VERITY_STANDARD_MODE.equals(apkVerityMode));
+
+        mKeyId = expectRemoteCommandToSucceed(
+                "mini-keyctl padd asymmetric fsv_test .fs-verity < " + CERT_PATH).trim();
+        if (!mKeyId.matches("^\\d+$")) {
+            String keyId = mKeyId;
+            mKeyId = null;
+            fail("Key ID is not decimal: " + keyId);
+        }
+
+        uninstallPackage(TARGET_PACKAGE);
+    }
+
+    @After
+    public void tearDown() throws DeviceNotAvailableException {
+        uninstallPackage(TARGET_PACKAGE);
+
+        if (mKeyId != null) {
+            expectRemoteCommandToSucceed("mini-keyctl unlink " + mKeyId + " .fs-verity");
+        }
+    }
+
+    @Test
+    public void testFsverityKernelSupports() throws DeviceNotAvailableException {
+        ITestDevice.MountPointInfo mountPoint = mDevice.getMountPointInfo("/data");
+        expectRemoteCommandToSucceed("test -f /sys/fs/" + mountPoint.type + "/features/verity");
+    }
+
+    @Test
+    public void testInstallBase() throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG);
+        verifyInstalledFilesHaveFsverity();
+    }
+
+    @Test
+    public void testInstallBaseWithWrongSignature()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFile(BASE_APK)
+                .addFile(SPLIT_APK_DM + ".fsv_sig",
+                        BASE_APK + ".fsv_sig")
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testInstallBaseWithSplit()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .addFileAndSignature(SPLIT_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG,
+                INSTALLED_SPLIT_APK,
+                INSTALLED_SPLIT_APK_FSV_SIG);
+        verifyInstalledFilesHaveFsverity();
+    }
+
+    @Test
+    public void testInstallBaseWithDm() throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .addFileAndSignature(BASE_APK_DM)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG,
+                INSTALLED_BASE_DM,
+                INSTALLED_BASE_DM_FSV_SIG);
+        verifyInstalledFilesHaveFsverity();
+    }
+
+    @Test
+    public void testInstallEverything() throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .addFileAndSignature(BASE_APK_DM)
+                .addFileAndSignature(SPLIT_APK)
+                .addFileAndSignature(SPLIT_APK_DM)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG,
+                INSTALLED_BASE_DM,
+                INSTALLED_BASE_DM_FSV_SIG,
+                INSTALLED_SPLIT_APK,
+                INSTALLED_SPLIT_APK_FSV_SIG,
+                INSTALLED_SPLIT_DM,
+                INSTALLED_SPLIT_DM_FSV_SIG);
+        verifyInstalledFilesHaveFsverity();
+    }
+
+    @Test
+    public void testInstallSplitOnly()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG);
+
+        new InstallMultiple()
+                .inheritFrom(TARGET_PACKAGE)
+                .addFileAndSignature(SPLIT_APK)
+                .run();
+
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG,
+                INSTALLED_SPLIT_APK,
+                INSTALLED_SPLIT_APK_FSV_SIG);
+        verifyInstalledFilesHaveFsverity();
+    }
+
+    @Test
+    public void testInstallSplitOnlyMissingSignature()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG);
+
+        new InstallMultiple()
+                .inheritFrom(TARGET_PACKAGE)
+                .addFile(SPLIT_APK)
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testInstallSplitOnlyWithoutBaseSignature()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFile(BASE_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+        verifyInstalledFiles(INSTALLED_BASE_APK);
+
+        new InstallMultiple()
+                .inheritFrom(TARGET_PACKAGE)
+                .addFileAndSignature(SPLIT_APK)
+                .run();
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_SPLIT_APK,
+                INSTALLED_SPLIT_APK_FSV_SIG);
+
+    }
+
+    @Test
+    public void testInstallOnlyBaseHasFsvSig()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .addFile(BASE_APK_DM)
+                .addFile(SPLIT_APK)
+                .addFile(SPLIT_APK_DM)
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testInstallOnlyDmHasFsvSig()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFile(BASE_APK)
+                .addFileAndSignature(BASE_APK_DM)
+                .addFile(SPLIT_APK)
+                .addFile(SPLIT_APK_DM)
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testInstallOnlySplitHasFsvSig()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFile(BASE_APK)
+                .addFile(BASE_APK_DM)
+                .addFileAndSignature(SPLIT_APK)
+                .addFile(SPLIT_APK_DM)
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testInstallBaseWithFsvSigThenSplitWithout()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFileAndSignature(BASE_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+        verifyInstalledFiles(
+                INSTALLED_BASE_APK,
+                INSTALLED_BASE_APK_FSV_SIG);
+
+        new InstallMultiple()
+                .addFile(SPLIT_APK)
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testInstallBaseWithoutFsvSigThenSplitWith()
+            throws DeviceNotAvailableException, FileNotFoundException {
+        new InstallMultiple()
+                .addFile(BASE_APK)
+                .run();
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+        verifyInstalledFiles(INSTALLED_BASE_APK);
+
+        new InstallMultiple()
+                .addFileAndSignature(SPLIT_APK)
+                .runExpectingFailure();
+    }
+
+    @Test
+    public void testFsverityFileIsImmutableAndReadable() throws DeviceNotAvailableException {
+        new InstallMultiple().addFileAndSignature(BASE_APK).run();
+        String apkPath = getApkPath(TARGET_PACKAGE);
+
+        assertNotNull(getDevice().getAppPackageInfo(TARGET_PACKAGE));
+        expectRemoteCommandToFail("echo -n '' >> " + apkPath);
+        expectRemoteCommandToSucceed("cat " + apkPath + " > /dev/null");
+    }
+
+    @Test
+    public void testFsverityFailToReadModifiedBlockAtFront() throws DeviceNotAvailableException {
+        new InstallMultiple().addFileAndSignature(BASE_APK).run();
+        String apkPath = getApkPath(TARGET_PACKAGE);
+
+        long apkSize = getFileSizeInBytes(apkPath);
+        long offsetFirstByte = 0;
+
+        // The first two pages should be both readable at first.
+        assertTrue(canReadByte(apkPath, offsetFirstByte));
+        if (apkSize > offsetFirstByte + FSVERITY_PAGE_SIZE) {
+            assertTrue(canReadByte(apkPath, offsetFirstByte + FSVERITY_PAGE_SIZE));
+        }
+
+        // Damage the file directly against the block device.
+        damageFileAgainstBlockDevice(apkPath, offsetFirstByte);
+
+        // Expect actual read from disk to fail but only at damaged page.
+        dropCaches();
+        assertFalse(canReadByte(apkPath, offsetFirstByte));
+        if (apkSize > offsetFirstByte + FSVERITY_PAGE_SIZE) {
+            long lastByteOfTheSamePage =
+                    offsetFirstByte % FSVERITY_PAGE_SIZE + FSVERITY_PAGE_SIZE - 1;
+            assertFalse(canReadByte(apkPath, lastByteOfTheSamePage));
+            assertTrue(canReadByte(apkPath, lastByteOfTheSamePage + 1));
+        }
+    }
+
+    @Test
+    public void testFsverityFailToReadModifiedBlockAtBack() throws DeviceNotAvailableException {
+        new InstallMultiple().addFileAndSignature(BASE_APK).run();
+        String apkPath = getApkPath(TARGET_PACKAGE);
+
+        long apkSize = getFileSizeInBytes(apkPath);
+        long offsetOfLastByte = apkSize - 1;
+
+        // The first two pages should be both readable at first.
+        assertTrue(canReadByte(apkPath, offsetOfLastByte));
+        if (offsetOfLastByte - FSVERITY_PAGE_SIZE > 0) {
+            assertTrue(canReadByte(apkPath, offsetOfLastByte - FSVERITY_PAGE_SIZE));
+        }
+
+        // Damage the file directly against the block device.
+        damageFileAgainstBlockDevice(apkPath, offsetOfLastByte);
+
+        // Expect actual read from disk to fail but only at damaged page.
+        dropCaches();
+        assertFalse(canReadByte(apkPath, offsetOfLastByte));
+        if (offsetOfLastByte - FSVERITY_PAGE_SIZE > 0) {
+            long firstByteOfTheSamePage = offsetOfLastByte - offsetOfLastByte % FSVERITY_PAGE_SIZE;
+            assertFalse(canReadByte(apkPath, firstByteOfTheSamePage));
+            assertTrue(canReadByte(apkPath, firstByteOfTheSamePage - 1));
+        }
+    }
+
+    private void verifyInstalledFilesHaveFsverity() throws DeviceNotAvailableException {
+        // Verify that all files are protected by fs-verity
+        String apkPath = getApkPath(TARGET_PACKAGE);
+        String appDir = apkPath.substring(0, apkPath.lastIndexOf("/"));
+        long kTargetOffset = 0;
+        for (String basename : expectRemoteCommandToSucceed("ls " + appDir).split("\n")) {
+            if (basename.endsWith(".apk") || basename.endsWith(".dm")) {
+                String path = appDir + "/" + basename;
+                damageFileAgainstBlockDevice(path, kTargetOffset);
+
+                // Retry is sometimes needed to pass the test. Package manager may have FD leaks
+                // (see b/122744005 as example) that prevents the file in question to be evicted
+                // from filesystem cache. Forcing GC workarounds the problem.
+                int retry = 5;
+                for (; retry > 0; retry--) {
+                    dropCaches();
+                    if (!canReadByte(path, kTargetOffset)) {
+                        break;
+                    }
+                    try {
+                        Thread.sleep(1000);
+                        String pid = expectRemoteCommandToSucceed("pidof system_server");
+                        mDevice.executeShellV2Command("kill -10 " + pid);  // force GC
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        return;
+                    }
+                }
+                assertTrue("Read from " + path + " should fail", retry > 0);
+            }
+        }
+    }
+
+    private void verifyInstalledFiles(String... filenames) throws DeviceNotAvailableException {
+        String apkPath = getApkPath(TARGET_PACKAGE);
+        String appDir = apkPath.substring(0, apkPath.lastIndexOf("/"));
+        HashSet<String> actualFiles = new HashSet<>(Arrays.asList(
+                expectRemoteCommandToSucceed("ls " + appDir).split("\n")));
+        assertTrue(actualFiles.remove("lib"));
+        assertTrue(actualFiles.remove("oat"));
+
+        HashSet<String> expectedFiles = new HashSet<>(Arrays.asList(filenames));
+        assertEquals(expectedFiles, actualFiles);
+    }
+
+    private void damageFileAgainstBlockDevice(String path, long offsetOfTargetingByte)
+            throws DeviceNotAvailableException {
+        assertTrue(path.startsWith("/data/"));
+        ITestDevice.MountPointInfo mountPoint = mDevice.getMountPointInfo("/data");
+        expectRemoteCommandToSucceed(String.join(" ", DAMAGING_EXECUTABLE,
+                    mountPoint.filesystem, path, Long.toString(offsetOfTargetingByte)));
+    }
+
+    private String getApkPath(String packageName) throws DeviceNotAvailableException {
+        String line = expectRemoteCommandToSucceed("pm path " + packageName + " | grep base.apk");
+        int index = line.trim().indexOf(":");
+        assertTrue(index >= 0);
+        return line.substring(index + 1);
+    }
+
+    private long getFileSizeInBytes(String packageName) throws DeviceNotAvailableException {
+        return Long.parseLong(expectRemoteCommandToSucceed("stat -c '%s' " + packageName).trim());
+    }
+
+    private void dropCaches() throws DeviceNotAvailableException {
+        expectRemoteCommandToSucceed("sync && echo 1 > /proc/sys/vm/drop_caches");
+    }
+
+    private boolean canReadByte(String filePath, long offset) throws DeviceNotAvailableException {
+        CommandResult result = mDevice.executeShellV2Command(
+                "dd if=" + filePath + " bs=1 count=1 skip=" + Long.toString(offset));
+        return result.getStatus() == CommandStatus.SUCCESS;
+    }
+
+    private String expectRemoteCommandToSucceed(String cmd) throws DeviceNotAvailableException {
+        CommandResult result = mDevice.executeShellV2Command(cmd);
+        assertEquals("`" + cmd + "` failed: " + result.getStderr(), CommandStatus.SUCCESS,
+                result.getStatus());
+        return result.getStdout();
+    }
+
+    private void expectRemoteCommandToFail(String cmd) throws DeviceNotAvailableException {
+        CommandResult result = mDevice.executeShellV2Command(cmd);
+        assertTrue("Unexpected success from `" + cmd + "`: " + result.getStderr(),
+                result.getStatus() != CommandStatus.SUCCESS);
+    }
+
+    private class InstallMultiple extends BaseInstallMultiple<InstallMultiple> {
+        InstallMultiple() {
+            super(getDevice(), getBuild());
+        }
+
+        InstallMultiple addFileAndSignature(String filename) {
+            try {
+                addFile(filename);
+                addFile(filename + ".fsv_sig");
+            } catch (FileNotFoundException e) {
+                fail("Missing test file: " + e);
+            }
+            return this;
+        }
+    }
+}
diff --git a/tests/ApkVerityTest/src/com/android/apkverity/BaseInstallMultiple.java b/tests/ApkVerityTest/src/com/android/apkverity/BaseInstallMultiple.java
new file mode 100644
index 0000000..02e73d1
--- /dev/null
+++ b/tests/ApkVerityTest/src/com/android/apkverity/BaseInstallMultiple.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2019 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.apkverity;
+
+import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper;
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+
+import junit.framework.TestCase;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Base class for invoking the install-multiple command via ADB. Subclass this for less typing:
+ *
+ * <code> private class InstallMultiple extends BaseInstallMultiple&lt;InstallMultiple&gt; { public
+ * InstallMultiple() { super(getDevice(), null); } } </code>
+ */
+/*package*/ class BaseInstallMultiple<T extends BaseInstallMultiple<?>> {
+
+    private final ITestDevice mDevice;
+    private final IBuildInfo mBuild;
+
+    private final List<String> mArgs = new ArrayList<>();
+    private final Map<File, String> mFileToRemoteMap = new HashMap<>();
+
+    /*package*/ BaseInstallMultiple(ITestDevice device, IBuildInfo buildInfo) {
+        mDevice = device;
+        mBuild = buildInfo;
+        addArg("-g");
+    }
+
+    T addArg(String arg) {
+        mArgs.add(arg);
+        return (T) this;
+    }
+
+    T addFile(String filename) throws FileNotFoundException {
+        return addFile(filename, filename);
+    }
+
+    T addFile(String filename, String remoteName) throws FileNotFoundException {
+        CompatibilityBuildHelper buildHelper = new CompatibilityBuildHelper(mBuild);
+        mFileToRemoteMap.put(buildHelper.getTestFile(filename), remoteName);
+        return (T) this;
+    }
+
+    T inheritFrom(String packageName) {
+        addArg("-r");
+        addArg("-p " + packageName);
+        return (T) this;
+    }
+
+    void run() throws DeviceNotAvailableException {
+        run(true);
+    }
+
+    void runExpectingFailure() throws DeviceNotAvailableException {
+        run(false);
+    }
+
+    private void run(boolean expectingSuccess) throws DeviceNotAvailableException {
+        final ITestDevice device = mDevice;
+
+        // Create an install session
+        final StringBuilder cmd = new StringBuilder();
+        cmd.append("pm install-create");
+        for (String arg : mArgs) {
+            cmd.append(' ').append(arg);
+        }
+
+        String result = device.executeShellCommand(cmd.toString());
+        TestCase.assertTrue(result, result.startsWith("Success"));
+
+        final int start = result.lastIndexOf("[");
+        final int end = result.lastIndexOf("]");
+        int sessionId = -1;
+        try {
+            if (start != -1 && end != -1 && start < end) {
+                sessionId = Integer.parseInt(result.substring(start + 1, end));
+            }
+        } catch (NumberFormatException e) {
+            throw new IllegalStateException("Failed to parse install session: " + result);
+        }
+        if (sessionId == -1) {
+            throw new IllegalStateException("Failed to create install session: " + result);
+        }
+
+        // Push our files into session. Ideally we'd use stdin streaming,
+        // but ddmlib doesn't support it yet.
+        for (final Map.Entry<File, String> entry : mFileToRemoteMap.entrySet()) {
+            final File file = entry.getKey();
+            final String remoteName  = entry.getValue();
+            final String remotePath = "/data/local/tmp/" + file.getName();
+            if (!device.pushFile(file, remotePath)) {
+                throw new IllegalStateException("Failed to push " + file);
+            }
+
+            cmd.setLength(0);
+            cmd.append("pm install-write");
+            cmd.append(' ').append(sessionId);
+            cmd.append(' ').append(remoteName);
+            cmd.append(' ').append(remotePath);
+
+            result = device.executeShellCommand(cmd.toString());
+            TestCase.assertTrue(result, result.startsWith("Success"));
+        }
+
+        // Everything staged; let's pull trigger
+        cmd.setLength(0);
+        cmd.append("pm install-commit");
+        cmd.append(' ').append(sessionId);
+
+        result = device.executeShellCommand(cmd.toString());
+        if (expectingSuccess) {
+            TestCase.assertTrue(result, result.contains("Success"));
+        } else {
+            TestCase.assertFalse(result, result.contains("Success"));
+        }
+    }
+}
diff --git a/tests/ApkVerityTest/testdata/Android.bp b/tests/ApkVerityTest/testdata/Android.bp
new file mode 100644
index 0000000..c10b0ce
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/Android.bp
@@ -0,0 +1,77 @@
+// Copyright (C) 2019 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.
+
+filegroup {
+    name: "ApkVerityTestKeyPem",
+    srcs: ["ApkVerityTestKey.pem"],
+}
+
+filegroup {
+    name: "ApkVerityTestCertPem",
+    srcs: ["ApkVerityTestCert.pem"],
+}
+
+filegroup {
+    name: "ApkVerityTestCertDer",
+    srcs: ["ApkVerityTestCert.der"],
+}
+
+filegroup {
+    name: "ApkVerityTestAppDm",
+    srcs: ["ApkVerityTestApp.dm"],
+}
+
+filegroup {
+    name: "ApkVerityTestAppSplitDm",
+    srcs: ["ApkVerityTestAppSplit.dm"],
+}
+
+genrule_defaults {
+    name: "apk_verity_sig_gen_default",
+    tools: ["fsverity"],
+    tool_files: [":ApkVerityTestKeyPem", ":ApkVerityTestCertPem"],
+    cmd: "$(location fsverity) sign $(in) $(out) " +
+        "--key=$(location :ApkVerityTestKeyPem) " +
+        "--cert=$(location :ApkVerityTestCertPem) " +
+        "> /dev/null",
+}
+
+genrule {
+    name: "ApkVerityTestAppFsvSig",
+    defaults: ["apk_verity_sig_gen_default"],
+    srcs: [":ApkVerityTestApp"],
+    out: ["ApkVerityTestApp.apk.fsv_sig"],
+}
+
+genrule {
+    name: "ApkVerityTestAppDmFsvSig",
+    defaults: ["apk_verity_sig_gen_default"],
+    srcs: [":ApkVerityTestAppDm"],
+    out: ["ApkVerityTestApp.dm.fsv_sig"],
+}
+
+genrule {
+    name: "ApkVerityTestAppSplitFsvSig",
+    defaults: ["apk_verity_sig_gen_default"],
+    srcs: [":ApkVerityTestAppSplit"],
+    out: ["ApkVerityTestAppSplit.apk.fsv_sig"],
+}
+
+genrule {
+    name: "ApkVerityTestAppSplitDmFsvSig",
+    defaults: ["apk_verity_sig_gen_default"],
+    srcs: [":ApkVerityTestAppSplitDm"],
+    out: ["ApkVerityTestAppSplit.dm.fsv_sig"],
+}
+
diff --git a/tests/ApkVerityTest/testdata/ApkVerityTestApp.dm b/tests/ApkVerityTest/testdata/ApkVerityTestApp.dm
new file mode 100644
index 0000000..e53a861
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/ApkVerityTestApp.dm
Binary files differ
diff --git a/tests/ApkVerityTest/testdata/ApkVerityTestAppSplit.dm b/tests/ApkVerityTest/testdata/ApkVerityTestAppSplit.dm
new file mode 100644
index 0000000..75396f1
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/ApkVerityTestAppSplit.dm
Binary files differ
diff --git a/tests/ApkVerityTest/testdata/ApkVerityTestCert.der b/tests/ApkVerityTest/testdata/ApkVerityTestCert.der
new file mode 100644
index 0000000..fe9029b
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/ApkVerityTestCert.der
Binary files differ
diff --git a/tests/ApkVerityTest/testdata/ApkVerityTestCert.pem b/tests/ApkVerityTest/testdata/ApkVerityTestCert.pem
new file mode 100644
index 0000000..6c0b7b1
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/ApkVerityTestCert.pem
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFLjCCAxagAwIBAgIJAKZbtMlZZwtdMA0GCSqGSIb3DQEBCwUAMCwxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEQMA4GA1UECgwHQW5kcm9pZDAeFw0xODEyMTky
+MTA5MzVaFw0xOTAxMTgyMTA5MzVaMCwxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJD
+QTEQMA4GA1UECgwHQW5kcm9pZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAKnrw4WiFgFBq6vXqcLc97iwvcYPZmeIjQqYRF+CHwXBXx8IyDlMfPrgyIYo
+ZLkosnUK/Exuypdu6UEtdqtYPknC6w9z4YkxqsKtyxyB1b13ptcTHh3bf2N8bqGr
+8gWWLxj0QjumCtFi7Z/TCwB5t3b3gtC+0jVfABSWrm5PNkgk7jIP+4KeYLDCDfiJ
+XH3uHu6OASiSHTOnrmLWSaSw0y6G4OFthHqQnMywasly0r6m+Mif+K0ZUV7hBRi/
+SfqcJ1HTCXTJMskEyV6Qx2sHF/VbK2gdUv56z6OVRNSs/FxPBiWVMuZZKh1FpBVI
+gbGxusf2Awwtc+Soxr4/P1YFcrwfA/ff9FK3Yg/Cd3ZMGbzUkbEMEkE5BW7Gbjmx
+wz3mYTiRfa2L/Bl4MiMqNi0tfORLkmg+V/EItzfhZ/HsXMOCBsnuj4KnFslmbamz
+t9opypj2JLGk+lXhZ5gFNFw8tYH1AnG1AIXe5u+6Fq2nQ1y/ncGUTR5Sw4de/Gee
+C0UgR+KiFEdKupMKbXgSKl+0QPz/i2eSpcDOKMwZ4WiNrkbccbCyr38so+j5DfWF
+IeZA9a/IlysA6G8yU2TfXBc65VCIEQRJOQdUOZFDO8OSoqGP+fbA6edpmovGw+TH
+sM/NkmpEXpQm7BVOI4oVjdf4pKPp0zaW2YcaA3xU2w6eF17pAgMBAAGjUzBRMB0G
+A1UdDgQWBBRGpHYy7yiLEYalGuF1va6zJKGD/zAfBgNVHSMEGDAWgBRGpHYy7yiL
+EYalGuF1va6zJKGD/zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IC
+AQAao6ZBM122F0pYb2QLahIyyGEr3LfSdBGID4068pVik4ncIefFz36Xf9AFxRQd
+KHmwRYNPHiLRIEGdtqplC5pZDeHz41txIArNIZKzDWOYtdcFyCz8umuj912BmsoM
+YUQhT6F1sX53SWcKxEP/aJ2kltSlPFX99e3Vx9eRkceV1oe2NM6ZG8hnYCfCAMeJ
+jRTpbqCGaAsEHFtIx6wt3zEtUXIVg4aYFQs/qjTjeP8ByIj0b4lZrceEoTeRimuj
++4aAI+jBxLkwaN3hseQHzRNpgPehIVV/0RU92yzOD/WN4YwE6rwjKEI1lihHNBDa
++DwGtGbHmIUzjW1qArig+mzUIhfYIJAxrx20ynPz/Q+C7+iXhTDAYQlxTle0pX8m
+yM2DUdPo97eLOzQ4JDHxtcN3ntTEJKKvrmzKvWuxy/yoLwS7MtLH6RETTHabH3Qd
+CP83X7z8zTyxgPxHdfHo9sgR/4C9RHGJx4OpBTQaiqfjSpDqJSIQdbrHGOQDgYwL
+KQyiQuhukmNgRCB6dJoZJ/MyaNuMsXV9QobsDHW1oSuCvPAihVoWHJxt8m4Ma0jJ
+EIbEPT2Umw1F/P+CeXnVQwhPvzQKHCa+6cC/YdjTqIKLmQV8X3HUBUIMhP2JGDic
+MnUipTm/RwWZVOjCJaFqk5sVq3L0Lyd0XVUWSK1a4IcrsA==
+-----END CERTIFICATE-----
diff --git a/tests/ApkVerityTest/testdata/ApkVerityTestKey.pem b/tests/ApkVerityTest/testdata/ApkVerityTestKey.pem
new file mode 100644
index 0000000..f0746c1
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/ApkVerityTestKey.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+p2aUlQOLXU9SjQTirZbdCZP0XHtFsg==
+-----END PRIVATE KEY-----
diff --git a/tests/ApkVerityTest/testdata/README.md b/tests/ApkVerityTest/testdata/README.md
new file mode 100644
index 0000000..163cb18
--- /dev/null
+++ b/tests/ApkVerityTest/testdata/README.md
@@ -0,0 +1,13 @@
+This test only runs on rooted / debuggable device.
+
+The test tries to install subsets of base.{apk,dm}, split.{apk,dm} and their
+corresponding .fsv_sig files (generated by build rule). If installed, the
+tests also tries to tamper with the file at absolute disk offset to verify
+if fs-verity is effective.
+
+How to generate dex metadata (.dm)
+==================================
+
+  adb shell profman --generate-test-profile=/data/local/tmp/primary.prof
+  adb pull /data/local/tmp/primary.prof
+  zip foo.dm primary.prof