Add tests to denial bad compos-pending artifacts

The new test CompOsDenialHostTest runs the compilation once, then save
the pending artifacts just to reduce the test time. The pending
artifacts is prepared for each test case to mess up with. Each test
expects odsign to deny those artifacts.

Common test utils are moved to OdsignTestUtils.

CompOS specific utils are moved to CompOsTestUtils.

Bug: 213573626
Test: atest com.android.tests.odsign.CompOsSigningHostTest
Test: atest com.android.tests.odsign.CompOsDenialHostTest

Change-Id: I08f815f775fc0f067d3e8a990affea2f2e5b7964
diff --git a/test/odsign/test-src/com/android/tests/odsign/CompOsDenialHostTest.java b/test/odsign/test-src/com/android/tests/odsign/CompOsDenialHostTest.java
new file mode 100644
index 0000000..4d66d34
--- /dev/null
+++ b/test/odsign/test-src/com/android/tests/odsign/CompOsDenialHostTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2022 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.tests.odsign;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assume.assumeTrue;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.junit4.AfterClassWithInfo;
+import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
+import com.android.tradefed.testtype.junit4.BeforeClassWithInfo;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.stream.Stream;
+
+/** Test to check if bad CompOS pending artifacts can be denied by odsign. */
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class CompOsDenialHostTest extends BaseHostJUnit4Test {
+
+    private static final String PENDING_ARTIFACTS_BACKUP_DIR =
+            "/data/misc/apexdata/com.android.art/compos-pending-backup";
+
+    private static final String TIMESTAMP_COMPOS_COMPILED_KEY = "compos_test_timestamp_compiled";
+
+    private OdsignTestUtils mTestUtils;
+    private String mFirstArch;
+
+    @BeforeClassWithInfo
+    public static void beforeClassWithDevice(TestInformation testInfo) throws Exception {
+        ITestDevice device = testInfo.getDevice();
+        OdsignTestUtils testUtils = new OdsignTestUtils(testInfo);
+        CompOsTestUtils compOsTestUtils = new CompOsTestUtils(device);
+
+        compOsTestUtils.assumeCompOsPresent();
+        testUtils.enableAdbRootOrSkipTest();
+
+        testUtils.installTestApex();
+
+        // Compile once, then backup the compiled artifacts to be reused by each test case just to
+        // reduce testing time.
+        compOsTestUtils.runCompilationJobEarlyAndWait();
+        testInfo.properties().put(TIMESTAMP_COMPOS_COMPILED_KEY,
+                String.valueOf(testUtils.getCurrentTimeMs()));
+        testUtils.assertCommandSucceeds(
+                "mv " + CompOsTestUtils.PENDING_ARTIFACTS_DIR + " " + PENDING_ARTIFACTS_BACKUP_DIR);
+    }
+
+    @AfterClassWithInfo
+    public static void afterClassWithDevice(TestInformation testInfo) throws Exception {
+        OdsignTestUtils testUtils = new OdsignTestUtils(testInfo);
+
+        // Remove all test states.
+        testInfo.getDevice().executeShellV2Command("rm -rf " +
+                CompOsTestUtils.PENDING_ARTIFACTS_DIR);
+        testInfo.getDevice().executeShellV2Command("rm -rf " + PENDING_ARTIFACTS_BACKUP_DIR);
+        testUtils.removeCompilationLogToAvoidBackoff();
+        testUtils.uninstallTestApex();
+
+        // Reboot should restore the device back to a good state.
+        testUtils.reboot();
+        testUtils.restoreAdbRoot();
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        mTestUtils = new OdsignTestUtils(getTestInformation());
+
+        mFirstArch = mTestUtils.assertCommandSucceeds("getprop ro.bionic.arch");
+
+        // Restore the pending artifacts for each test to mess up with.
+        mTestUtils.assertCommandSucceeds("rm -rf " + CompOsTestUtils.PENDING_ARTIFACTS_DIR);
+        mTestUtils.assertCommandSucceeds("cp -rp " + PENDING_ARTIFACTS_BACKUP_DIR + " " +
+                CompOsTestUtils.PENDING_ARTIFACTS_DIR);
+    }
+
+    @Test
+    public void denyDueToInconsistentFileName() throws Exception {
+        // Attack emulation: swap file names
+        String[] paths = getAllPendingOdexPaths();
+        assertThat(paths.length).isGreaterThan(1);
+        String odex1 = paths[0];
+        String odex2 = paths[1];
+        String temp = CompOsTestUtils.PENDING_ARTIFACTS_DIR + "/temp";
+        mTestUtils.assertCommandSucceeds(
+                "mv " + odex1 + " " + temp + " && " +
+                "mv " + odex2 + " " + odex1 + " && " +
+                "mv " + temp + " " + odex2);
+
+        // Expect the pending artifacts to be denied by odsign during the reboot.
+        mTestUtils.reboot();
+        expectNoCurrentFilesFromCompOs();
+    }
+
+    @Test
+    public void denyDueToMissingFile() throws Exception {
+        // Attack emulation: delete a file
+        String[] paths = getAllPendingOdexPaths();
+        assertThat(paths.length).isGreaterThan(0);
+        getDevice().deleteFile(paths[0]);
+
+        // Expect the pending artifacts to be denied by odsign during the reboot.
+        mTestUtils.reboot();
+        expectNoCurrentFilesFromCompOs();
+    }
+
+    private void expectNoCurrentFilesFromCompOs() throws DeviceNotAvailableException {
+        // None of the files should have a timestamp earlier than the first reboot.
+        long timestamp = Long.parseLong(getTestInformation().properties().get(
+                    TIMESTAMP_COMPOS_COMPILED_KEY));
+        int numFiles = mTestUtils.countFilesCreatedBeforeTime(
+                OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME,
+                timestamp);
+        assertThat(numFiles).isEqualTo(0);
+
+        // odsign should have deleted the pending directory.
+        assertThat(getDevice().isDirectory(CompOsTestUtils.PENDING_ARTIFACTS_DIR)).isFalse();
+    }
+
+    private String[] getAllPendingOdexPaths() throws DeviceNotAvailableException {
+        String dir = CompOsTestUtils.PENDING_ARTIFACTS_DIR + "/" + mFirstArch;
+        return Stream.of(getDevice().getChildren(dir))
+                .filter(name -> name.endsWith(".odex"))
+                .map(name -> dir + "/" + name)
+                .toArray(String[]::new);
+    }
+}
diff --git a/test/odsign/test-src/com/android/tests/odsign/CompOsSigningHostTest.java b/test/odsign/test-src/com/android/tests/odsign/CompOsSigningHostTest.java
index c646a44..1178130 100644
--- a/test/odsign/test-src/com/android/tests/odsign/CompOsSigningHostTest.java
+++ b/test/odsign/test-src/com/android/tests/odsign/CompOsSigningHostTest.java
@@ -19,7 +19,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
-import static org.junit.Assert.fail;
 import static org.junit.Assume.assumeTrue;
 
 import com.android.tradefed.device.DeviceNotAvailableException;
@@ -28,7 +27,6 @@
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
 import com.android.tradefed.testtype.junit4.AfterClassWithInfo;
 import com.android.tradefed.testtype.junit4.BeforeClassWithInfo;
-import com.android.tradefed.util.CommandResult;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -44,21 +42,6 @@
 @RunWith(DeviceJUnit4ClassRunner.class)
 public class CompOsSigningHostTest extends ActivationTest {
 
-    private static final String PENDING_ARTIFACTS_DIR =
-            "/data/misc/apexdata/com.android.art/compos-pending";
-
-    /** odrefresh is currently hard-coded to fail if it does not complete in 300 seconds. */
-    private static final int ODREFRESH_MAX_SECONDS = 300;
-
-    /** Waiting time for the job to be scheduled after staging an APEX */
-    private static final int JOB_CREATION_MAX_SECONDS = 5;
-
-    /** Waiting time before starting to check odrefresh progress. */
-    private static final int SECONDS_BEFORE_PROGRESS_CHECK = 30;
-
-    /** Job ID of the pending compilation with staged APEXes. */
-    private static final String JOB_ID = "5132251";
-
     private static final String ORIGINAL_CHECKSUMS_KEY = "compos_test_orig_checksums";
     private static final String PENDING_CHECKSUMS_KEY = "compos_test_pending_checksums";
     private static final String TIMESTAMP_VM_START_KEY = "compos_test_timestamp_vm_start";
@@ -67,35 +50,28 @@
     @BeforeClassWithInfo
     public static void beforeClassWithDevice(TestInformation testInfo) throws Exception {
         ITestDevice device = testInfo.getDevice();
+        OdsignTestUtils testUtils = new OdsignTestUtils(testInfo);
+        CompOsTestUtils compOsTestUtils = new CompOsTestUtils(device);
 
-        assumeCompOsPresent(device);
+        compOsTestUtils.assumeCompOsPresent();
 
         testInfo.properties().put(ORIGINAL_CHECKSUMS_KEY,
-                checksumDirectoryContentPartial(device,
-                    OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME));
+                compOsTestUtils.checksumDirectoryContentPartial(
+                        OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME));
 
-        OdsignTestUtils testUtils = new OdsignTestUtils(testInfo);
         testUtils.installTestApex();
 
-        testInfo.properties().put(TIMESTAMP_VM_START_KEY, getDeviceCurrentTimestamp(device));
+        testInfo.properties().put(TIMESTAMP_VM_START_KEY,
+                        String.valueOf(testUtils.getCurrentTimeMs()));
 
-        // Once the test APK is installed, a CompilationJob is (asynchronously) scheduled to run
-        // when certain criteria are met, e.g. the device is charging and idle. Since we don't
-        // want to wait in the test, here we start the job by ID as soon as it is scheduled.
-        waitForJobToBeScheduled(device, JOB_CREATION_MAX_SECONDS);
-        assertCommandSucceeds(device, "cmd jobscheduler run android " + JOB_ID);
-        // It takes time. Just don't spam.
-        TimeUnit.SECONDS.sleep(SECONDS_BEFORE_PROGRESS_CHECK);
-        // The job runs asynchronously. To wait until it completes.
-        waitForJobExit(device, ODREFRESH_MAX_SECONDS - SECONDS_BEFORE_PROGRESS_CHECK);
+        compOsTestUtils.runCompilationJobEarlyAndWait();
 
-        // Checks the output validity, then store the hashes of pending files.
-        assertThat(device.getChildren(PENDING_ARTIFACTS_DIR)).asList().containsAtLeast(
-                "cache-info.xml", "compos.info", "compos.info.signature");
         testInfo.properties().put(PENDING_CHECKSUMS_KEY,
-                checksumDirectoryContentPartial(device, PENDING_ARTIFACTS_DIR));
+                compOsTestUtils.checksumDirectoryContentPartial(
+                        CompOsTestUtils.PENDING_ARTIFACTS_DIR));
 
-        testInfo.properties().put(TIMESTAMP_REBOOT_KEY, getDeviceCurrentTimestamp(device));
+        testInfo.properties().put(TIMESTAMP_REBOOT_KEY,
+                        String.valueOf(testUtils.getCurrentTimeMs()));
         testUtils.reboot();
     }
 
@@ -109,7 +85,8 @@
 
     @Test
     public void checkFileChecksums() throws Exception {
-        String actualChecksums = checksumDirectoryContentPartial(getDevice(),
+        CompOsTestUtils compOsTestUtils = new CompOsTestUtils(getDevice());
+        String actualChecksums = compOsTestUtils.checksumDirectoryContentPartial(
                 OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME);
 
         String pendingChecksums = getTestInformation().properties().get(PENDING_CHECKSUMS_KEY);
@@ -122,110 +99,24 @@
 
     @Test
     public void checkFileCreationTimeAfterVmStartAndBeforeReboot() throws Exception {
+        OdsignTestUtils testUtils = new OdsignTestUtils(getTestInformation());
+
         // No files are created before our VM starts.
-        int numFiles = countFilesCreatedBeforeTime(
-                getDevice(),
+        int numFiles = testUtils.countFilesCreatedBeforeTime(
                 OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME,
-                getTestInformation().properties().get(TIMESTAMP_VM_START_KEY));
+                Long.parseLong(getTestInformation().properties().get(TIMESTAMP_VM_START_KEY)));
         assertThat(numFiles).isEqualTo(0);
 
         // (All) Files are created after our VM starts.
-        numFiles = countFilesCreatedAfterTime(
-                getDevice(),
+        numFiles = testUtils.countFilesCreatedAfterTime(
                 OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME,
-                getTestInformation().properties().get(TIMESTAMP_VM_START_KEY));
+                Long.parseLong(getTestInformation().properties().get(TIMESTAMP_VM_START_KEY)));
         assertThat(numFiles).isGreaterThan(0);
 
         // No files are created after reboot.
-        numFiles = countFilesCreatedAfterTime(
-                getDevice(),
+        numFiles = testUtils.countFilesCreatedAfterTime(
                 OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME,
-                getTestInformation().properties().get(TIMESTAMP_REBOOT_KEY));
+                Long.parseLong(getTestInformation().properties().get(TIMESTAMP_REBOOT_KEY)));
         assertThat(numFiles).isEqualTo(0);
     }
-
-    private static String checksumDirectoryContentPartial(ITestDevice device, String path)
-            throws Exception {
-        // Sort by filename (second column) to make comparison easier.
-        // Filter out compos.info* (which will be deleted at boot) and cache-info.xm
-        // compos.info.signature since it's only generated by CompOS.
-        // TODO(b/210473615): Remove irrelevant APEXes (i.e. those aren't contributing to the
-        // classpaths, thus not in the VM) from cache-info.xml.
-        return assertCommandSucceeds(device, "cd " + path + "; find -type f -exec sha256sum {} \\;"
-                + "| grep -v cache-info.xml | grep -v compos.info"
-                + "| sort -k2");
-    }
-
-    private static String getDeviceCurrentTimestamp(ITestDevice device)
-            throws DeviceNotAvailableException {
-        return assertCommandSucceeds(device, "date +'%s'");
-    }
-
-    private static int countFilesCreatedBeforeTime(ITestDevice device, String directory,
-            String timestamp) throws DeviceNotAvailableException {
-        // For simplicity, directory must be a simple path that doesn't require escaping.
-        String output = assertCommandSucceeds(device,
-                "find " + directory + " -type f ! -newerct '@" + timestamp + "' | wc -l");
-        return Integer.parseInt(output);
-    }
-
-    private static int countFilesCreatedAfterTime(ITestDevice device, String directory,
-            String timestamp) throws DeviceNotAvailableException {
-        // For simplicity, directory must be a simple path that doesn't require escaping.
-        String output = assertCommandSucceeds(device,
-                "find " + directory + " -type f -newerct '@" + timestamp + "' | wc -l");
-        return Integer.parseInt(output);
-    }
-
-    private static String assertCommandSucceeds(ITestDevice device, String command)
-            throws DeviceNotAvailableException {
-        CommandResult result = device.executeShellV2Command(command);
-        assertWithMessage(result.toString()).that(result.getExitCode()).isEqualTo(0);
-        return result.getStdout().trim();
-    }
-
-    private static void waitForJobToBeScheduled(ITestDevice device, int timeout)
-            throws Exception {
-        for (int i = 0; i < timeout; i++) {
-            CommandResult result = device.executeShellV2Command(
-                    "cmd jobscheduler get-job-state android " + JOB_ID);
-            String state = result.getStdout().toString();
-            if (state.startsWith("unknown")) {
-                // The job hasn't been scheduled yet. So try again.
-                TimeUnit.SECONDS.sleep(1);
-            } else if (result.getExitCode() != 0) {
-                fail("Failing due to unexpected job state: " + result);
-            } else {
-                // The job exists, which is all we care about here
-                return;
-            }
-        }
-        fail("Timed out waiting for the job to be scheduled");
-    }
-
-    private static void waitForJobExit(ITestDevice device, int timeout)
-            throws Exception {
-        for (int i = 0; i < timeout; i++) {
-            CommandResult result = device.executeShellV2Command(
-                    "cmd jobscheduler get-job-state android " + JOB_ID);
-            String state = result.getStdout().toString();
-            if (state.contains("ready") || state.contains("active")) {
-                TimeUnit.SECONDS.sleep(1);
-            } else if (state.startsWith("unknown")) {
-                // Job has completed
-                return;
-            } else {
-                fail("Failing due to unexpected job state: " + result);
-            }
-        }
-        fail("Timed out waiting for the job to complete");
-    }
-
-    public static void assumeCompOsPresent(ITestDevice device) throws Exception {
-        // We have to have kernel support for a VM.
-        assumeTrue(device.doesFileExist("/dev/kvm"));
-
-        // And the CompOS APEX must be present.
-        assumeTrue(device.doesFileExist("/apex/com.android.compos/"));
-    }
 }
diff --git a/test/odsign/test-src/com/android/tests/odsign/CompOsTestUtils.java b/test/odsign/test-src/com/android/tests/odsign/CompOsTestUtils.java
new file mode 100644
index 0000000..e86c096
--- /dev/null
+++ b/test/odsign/test-src/com/android/tests/odsign/CompOsTestUtils.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2022 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.tests.odsign;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.util.CommandResult;
+
+import java.util.concurrent.TimeUnit;
+
+public class CompOsTestUtils {
+    public static final String PENDING_ARTIFACTS_DIR =
+            "/data/misc/apexdata/com.android.art/compos-pending";
+
+    /** odrefresh is currently hard-coded to fail if it does not complete in 300 seconds. */
+    private static final int ODREFRESH_MAX_SECONDS = 300;
+
+    /** Waiting time for the job to be scheduled after staging an APEX */
+    private static final int JOB_CREATION_MAX_SECONDS = 5;
+
+    /** Waiting time before starting to check odrefresh progress. */
+    private static final int SECONDS_BEFORE_PROGRESS_CHECK = 30;
+
+    /** Job ID of the pending compilation with staged APEXes. */
+    private static final String JOB_ID = "5132251";
+
+    private final ITestDevice mDevice;
+
+    public CompOsTestUtils(ITestDevice device) {
+        mDevice = device;
+    }
+
+    /**
+     * Start CompOS compilation right away, and return once the job completes successfully.
+     *
+     * <p>Once the test APK is installed, a CompilationJob is (asynchronously) scheduled to run
+     * when certain criteria are met, e.g. the device is charging and idle. Since we don't want
+     * to wait in the test, here we start the job by ID as soon as it is scheduled.
+     */
+    public void runCompilationJobEarlyAndWait() throws Exception {
+        waitForJobToBeScheduled();
+
+        assertCommandSucceeds("cmd jobscheduler run android " + JOB_ID);
+        // It takes time. Just don't spam.
+        TimeUnit.SECONDS.sleep(SECONDS_BEFORE_PROGRESS_CHECK);
+        // The job runs asynchronously. To wait until it completes.
+        waitForJobExit(ODREFRESH_MAX_SECONDS - SECONDS_BEFORE_PROGRESS_CHECK);
+
+        // Checks the output validity, then store the hashes of pending files.
+        assertThat(mDevice.getChildren(PENDING_ARTIFACTS_DIR)).asList().containsAtLeast(
+                "cache-info.xml", "compos.info", "compos.info.signature");
+    }
+
+    public String checksumDirectoryContentPartial(String path) throws Exception {
+        // Sort by filename (second column) to make comparison easier.
+        // Filter out compos.info* (which will be deleted at boot) and cache-info.xml
+        // compos.info.signature since it's only generated by CompOS.
+        // TODO(b/210473615): Remove irrelevant APEXes (i.e. those aren't contributing to the
+        // classpaths, thus not in the VM) from cache-info.xml.
+        return assertCommandSucceeds("cd " + path + "; find -type f -exec sha256sum {} \\;"
+                + "| grep -v cache-info.xml | grep -v compos.info"
+                + "| sort -k2");
+    }
+
+    private void waitForJobToBeScheduled()
+            throws Exception {
+        for (int i = 0; i < JOB_CREATION_MAX_SECONDS; i++) {
+            CommandResult result = mDevice.executeShellV2Command(
+                    "cmd jobscheduler get-job-state android " + JOB_ID);
+            String state = result.getStdout().toString();
+            if (state.startsWith("unknown")) {
+                // The job hasn't been scheduled yet. So try again.
+                TimeUnit.SECONDS.sleep(1);
+            } else if (result.getExitCode() != 0) {
+                fail("Failing due to unexpected job state: " + result);
+            } else {
+                // The job exists, which is all we care about here
+                return;
+            }
+        }
+        fail("Timed out waiting for the job to be scheduled");
+    }
+
+    private void waitForJobExit(int timeout) throws Exception {
+        for (int i = 0; i < timeout; i++) {
+            CommandResult result = mDevice.executeShellV2Command(
+                    "cmd jobscheduler get-job-state android " + JOB_ID);
+            String state = result.getStdout().toString();
+            if (state.contains("ready") || state.contains("active")) {
+                TimeUnit.SECONDS.sleep(1);
+            } else if (state.startsWith("unknown")) {
+                // Job has completed
+                return;
+            } else {
+                fail("Failing due to unexpected job state: " + result);
+            }
+        }
+        fail("Timed out waiting for the job to complete");
+    }
+
+    public void assumeCompOsPresent() throws Exception {
+        // We have to have kernel support for a VM.
+        assumeTrue(mDevice.doesFileExist("/dev/kvm"));
+
+        // And the CompOS APEX must be present.
+        assumeTrue(mDevice.doesFileExist("/apex/com.android.compos/"));
+    }
+
+    private String assertCommandSucceeds(String command) throws DeviceNotAvailableException {
+        CommandResult result = mDevice.executeShellV2Command(command);
+        assertWithMessage(result.toString()).that(result.getExitCode()).isEqualTo(0);
+        return result.getStdout().trim();
+    }
+}
diff --git a/test/odsign/test-src/com/android/tests/odsign/OdrefreshHostTest.java b/test/odsign/test-src/com/android/tests/odsign/OdrefreshHostTest.java
index 7708aaa..d060638 100644
--- a/test/odsign/test-src/com/android/tests/odsign/OdrefreshHostTest.java
+++ b/test/odsign/test-src/com/android/tests/odsign/OdrefreshHostTest.java
@@ -31,8 +31,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -94,7 +92,7 @@
     @Test
     public void verifyArtSamegradeUpdateTriggersCompilation() throws Exception {
         simulateArtApexUpgrade();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         assertArtifactsModifiedAfter(getZygoteArtifacts(), timeMs);
@@ -104,7 +102,7 @@
     @Test
     public void verifyOtherApexSamegradeUpdateTriggersCompilation() throws Exception {
         simulateApexUpgrade();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         assertArtifactsNotModifiedAfter(getZygoteArtifacts(), timeMs);
@@ -114,7 +112,7 @@
     @Test
     public void verifyBootClasspathOtaTriggersCompilation() throws Exception {
         simulateBootClasspathOta();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         assertArtifactsModifiedAfter(getZygoteArtifacts(), timeMs);
@@ -124,7 +122,7 @@
     @Test
     public void verifySystemServerOtaTriggersCompilation() throws Exception {
         simulateSystemServerOta();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         assertArtifactsNotModifiedAfter(getZygoteArtifacts(), timeMs);
@@ -140,7 +138,7 @@
         remainingArtifacts.removeAll(missingArtifacts);
 
         mTestUtils.removeCompilationLogToAvoidBackoff();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         assertArtifactsNotModifiedAfter(remainingArtifacts, timeMs);
@@ -150,7 +148,7 @@
     @Test
     public void verifyNoCompilationWhenCacheIsGood() throws Exception {
         mTestUtils.removeCompilationLogToAvoidBackoff();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         assertArtifactsNotModifiedAfter(getZygoteArtifacts(), timeMs);
@@ -184,7 +182,7 @@
     public void verifyCompilationOsMode() throws Exception {
         mTestUtils.removeCompilationLogToAvoidBackoff();
         simulateApexUpgrade();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(
                 ODREFRESH_BIN + " --no-refresh --partial-compilation"
                         + " --compilation-os-mode --compile");
@@ -199,7 +197,7 @@
         mTestUtils.removeCompilationLogToAvoidBackoff();
 
         // Simulate the odrefresh invocation on the next boot.
-        timeMs = getCurrentTimeMs();
+        timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         // odrefresh should not re-compile anything.
@@ -222,21 +220,21 @@
 
         // Running the command again should not overwrite the minimal boot image.
         mTestUtils.removeCompilationLogToAvoidBackoff();
-        long timeMs = getCurrentTimeMs();
+        long timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_MINIMAL_COMMAND);
 
         assertArtifactsNotModifiedAfter(minimalZygoteArtifacts, timeMs);
 
         // `odrefresh --check` should keep the minimal boot image.
         mTestUtils.removeCompilationLogToAvoidBackoff();
-        timeMs = getCurrentTimeMs();
+        timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_BIN + " --check");
 
         assertArtifactsNotModifiedAfter(minimalZygoteArtifacts, timeMs);
 
         // A normal odrefresh invocation should replace the minimal boot image with a full one.
         mTestUtils.removeCompilationLogToAvoidBackoff();
-        timeMs = getCurrentTimeMs();
+        timeMs = mTestUtils.getCurrentTimeMs();
         getDevice().executeShellV2Command(ODREFRESH_COMMAND);
 
         for (String artifact : minimalZygoteArtifacts) {
@@ -332,35 +330,9 @@
         return missingArtifacts;
     }
 
-    private long parseFormattedDateTime(String dateTimeStr) throws Exception {
-        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(
-                "yyyy-MM-dd HH:mm:ss.nnnnnnnnn Z");
-        ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeStr, formatter);
-        return zonedDateTime.toInstant().toEpochMilli();
-    }
-
-    private long getModifiedTimeMs(String filename) throws Exception {
-        // We can't use the "-c '%.3Y'" flag when to get the timestamp because the Toybox's `stat`
-        // implementation truncates the timestamp to seconds, which is not accurate enough, so we
-        // use "-c '%%y'" and parse the time ourselves.
-        String dateTimeStr = getDevice()
-                .executeShellCommand(String.format("stat -c '%%y' '%s'", filename))
-                .trim();
-        return parseFormattedDateTime(dateTimeStr);
-    }
-
-    private long getCurrentTimeMs() throws Exception {
-        // We can't use getDevice().getDeviceDate() because it truncates the timestamp to seconds,
-        // which is not accurate enough.
-        String dateTimeStr = getDevice()
-                .executeShellCommand("date +'%Y-%m-%d %H:%M:%S.%N %z'")
-                .trim();
-        return parseFormattedDateTime(dateTimeStr);
-    }
-
     private void assertArtifactsModifiedAfter(Set<String> artifacts, long timeMs) throws Exception {
         for (String artifact : artifacts) {
-            long modifiedTime = getModifiedTimeMs(artifact);
+            long modifiedTime = mTestUtils.getModifiedTimeMs(artifact);
             assertTrue(
                     String.format(
                             "Artifact %s is not re-compiled. Modified time: %d, Reference time: %d",
@@ -374,7 +346,7 @@
     private void assertArtifactsNotModifiedAfter(Set<String> artifacts, long timeMs)
             throws Exception {
         for (String artifact : artifacts) {
-            long modifiedTime = getModifiedTimeMs(artifact);
+            long modifiedTime = mTestUtils.getModifiedTimeMs(artifact);
             assertTrue(
                     String.format(
                             "Artifact %s is unexpectedly re-compiled. " +
diff --git a/test/odsign/test-src/com/android/tests/odsign/OdsignTestUtils.java b/test/odsign/test-src/com/android/tests/odsign/OdsignTestUtils.java
index 48644e7..bad206f 100644
--- a/test/odsign/test-src/com/android/tests/odsign/OdsignTestUtils.java
+++ b/test/odsign/test-src/com/android/tests/odsign/OdsignTestUtils.java
@@ -24,11 +24,14 @@
 
 import android.cts.install.lib.host.InstallUtilsHost;
 
+import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.device.ITestDevice.ApexInfo;
 import com.android.tradefed.invoker.TestInformation;
 import com.android.tradefed.util.CommandResult;
 
 import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
@@ -275,4 +278,58 @@
         String[] pathComponents = mappedArtifact.split("/");
         return pathComponents[pathComponents.length - 2];
     }
+
+    private long parseFormattedDateTime(String dateTimeStr) throws Exception {
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(
+                "yyyy-MM-dd HH:mm:ss.nnnnnnnnn Z");
+        ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeStr, formatter);
+        return zonedDateTime.toInstant().toEpochMilli();
+    }
+
+    public long getModifiedTimeMs(String filename) throws Exception {
+        // We can't use the "-c '%.3Y'" flag when to get the timestamp because the Toybox's `stat`
+        // implementation truncates the timestamp to seconds, which is not accurate enough, so we
+        // use "-c '%%y'" and parse the time ourselves.
+        String dateTimeStr = mTestInfo.getDevice()
+                .executeShellCommand(String.format("stat -c '%%y' '%s'", filename))
+                .trim();
+        return parseFormattedDateTime(dateTimeStr);
+    }
+
+    public long getCurrentTimeMs() throws Exception {
+        // We can't use getDevice().getDeviceDate() because it truncates the timestamp to seconds,
+        // which is not accurate enough.
+        String dateTimeStr = mTestInfo.getDevice()
+                .executeShellCommand("date +'%Y-%m-%d %H:%M:%S.%N %z'")
+                .trim();
+        return parseFormattedDateTime(dateTimeStr);
+    }
+
+    public int countFilesCreatedBeforeTime(String directory, long timestampMs)
+            throws DeviceNotAvailableException {
+        // Drop the precision to second, mainly because we need to use `find -newerct` to query
+        // files by timestamp, but toybox can't parse `date +'%s.%N'` currently.
+        String timestamp = String.valueOf(timestampMs / 1000);
+        // For simplicity, directory must be a simple path that doesn't require escaping.
+        String output = assertCommandSucceeds(
+                "find " + directory + " -type f ! -newerct '@" + timestamp + "' | wc -l");
+        return Integer.parseInt(output);
+    }
+
+    public int countFilesCreatedAfterTime(String directory, long timestampMs)
+            throws DeviceNotAvailableException {
+        // Drop the precision to second, mainly because we need to use `find -newerct` to query
+        // files by timestamp, but toybox can't parse `date +'%s.%N'` currently.
+        String timestamp = String.valueOf(timestampMs / 1000);
+        // For simplicity, directory must be a simple path that doesn't require escaping.
+        String output = assertCommandSucceeds(
+                "find " + directory + " -type f -newerct '@" + timestamp + "' | wc -l");
+        return Integer.parseInt(output);
+    }
+
+    public String assertCommandSucceeds(String command) throws DeviceNotAvailableException {
+        CommandResult result = mTestInfo.getDevice().executeShellV2Command(command);
+        assertWithMessage(result.toString()).that(result.getExitCode()).isEqualTo(0);
+        return result.getStdout().trim();
+    }
 }