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<InstallMultiple> { 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