Move distro installer code to system/timezone
am: 71a6a6fdb8

Change-Id: I37efd87a6d0c8041559ff968787f7606ef829842
diff --git a/distro/installer/Android.mk b/distro/installer/Android.mk
new file mode 100644
index 0000000..2b22725
--- /dev/null
+++ b/distro/installer/Android.mk
@@ -0,0 +1,35 @@
+# Copyright (C) 2015 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.
+
+LOCAL_PATH:= $(call my-dir)
+
+# The classes needed to handle installation of time zone distros.
+include $(CLEAR_VARS)
+LOCAL_MODULE := time_zone_distro_installer
+LOCAL_MODULE_TAGS := optional
+LOCAL_SRC_FILES := $(call all-java-files-under, src/main)
+LOCAL_JAVA_LIBRARIES := time_zone_distro
+include $(BUILD_STATIC_JAVA_LIBRARY)
+
+# Tests for time_zone_distro_installer code
+include $(CLEAR_VARS)
+LOCAL_MODULE := time_zone_distro_installer-tests
+LOCAL_MODULE_TAGS := optional
+LOCAL_SRC_FILES := $(call all-java-files-under, src/test)
+LOCAL_STATIC_JAVA_LIBRARIES := time_zone_distro \
+                               time_zone_distro_tools \
+                               time_zone_distro_installer \
+                               tzdata-testing \
+                               junit
+include $(BUILD_STATIC_JAVA_LIBRARY)
diff --git a/distro/installer/src/main/com/android/timezone/distro/installer/TimeZoneDistroInstaller.java b/distro/installer/src/main/com/android/timezone/distro/installer/TimeZoneDistroInstaller.java
new file mode 100644
index 0000000..870fd31
--- /dev/null
+++ b/distro/installer/src/main/com/android/timezone/distro/installer/TimeZoneDistroInstaller.java
@@ -0,0 +1,374 @@
+/*
+ * Copyright (C) 2015 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.timezone.distro.installer;
+
+import com.android.timezone.distro.DistroException;
+import com.android.timezone.distro.DistroVersion;
+import com.android.timezone.distro.FileUtils;
+import com.android.timezone.distro.StagedDistroOperation;
+import com.android.timezone.distro.TimeZoneDistro;
+
+import android.util.Slog;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import libcore.util.TimeZoneFinder;
+import libcore.util.ZoneInfoDB;
+
+/**
+ * A distro-validation / extraction class. Separate from the services code that uses it for easier
+ * testing. This class is not thread-safe: callers are expected to handle mutual exclusion.
+ */
+public class TimeZoneDistroInstaller {
+    /** {@link #stageInstallWithErrorCode(TimeZoneDistro)} result code: Success. */
+    public final static int INSTALL_SUCCESS = 0;
+    /** {@link #stageInstallWithErrorCode(TimeZoneDistro)} result code: Distro corrupt. */
+    public final static int INSTALL_FAIL_BAD_DISTRO_STRUCTURE = 1;
+    /** {@link #stageInstallWithErrorCode(TimeZoneDistro)} result code: Distro version incompatible. */
+    public final static int INSTALL_FAIL_BAD_DISTRO_FORMAT_VERSION = 2;
+    /** {@link #stageInstallWithErrorCode(TimeZoneDistro)} result code: Distro rules too old for device. */
+    public final static int INSTALL_FAIL_RULES_TOO_OLD = 3;
+    /** {@link #stageInstallWithErrorCode(TimeZoneDistro)} result code: Distro content failed validation. */
+    public final static int INSTALL_FAIL_VALIDATION_ERROR = 4;
+
+    // This constant must match one in system/timezone/tzdatacheck/tzdatacheck.cpp.
+    private static final String STAGED_TZ_DATA_DIR_NAME = "staged";
+    // This constant must match one in system/timezone/tzdatacheck/tzdatacheck.cpp.
+    private static final String CURRENT_TZ_DATA_DIR_NAME = "current";
+    private static final String WORKING_DIR_NAME = "working";
+    private static final String OLD_TZ_DATA_DIR_NAME = "old";
+
+    /**
+     * The name of the file in the staged directory used to indicate a staged uninstallation.
+     */
+    // This constant must match one in system/timezone/tzdatacheck/tzdatacheck.cpp.
+    // VisibleForTesting.
+    public static final String UNINSTALL_TOMBSTONE_FILE_NAME = "STAGED_UNINSTALL_TOMBSTONE";
+
+    private final String logTag;
+    private final File systemTzDataFile;
+    private final File oldStagedDataDir;
+    private final File stagedTzDataDir;
+    private final File currentTzDataDir;
+    private final File workingDir;
+
+    public TimeZoneDistroInstaller(String logTag, File systemTzDataFile, File installDir) {
+        this.logTag = logTag;
+        this.systemTzDataFile = systemTzDataFile;
+        oldStagedDataDir = new File(installDir, OLD_TZ_DATA_DIR_NAME);
+        stagedTzDataDir = new File(installDir, STAGED_TZ_DATA_DIR_NAME);
+        currentTzDataDir = new File(installDir, CURRENT_TZ_DATA_DIR_NAME);
+        workingDir = new File(installDir, WORKING_DIR_NAME);
+    }
+
+    // VisibleForTesting
+    File getOldStagedDataDir() {
+        return oldStagedDataDir;
+    }
+
+    // VisibleForTesting
+    File getStagedTzDataDir() {
+        return stagedTzDataDir;
+    }
+
+    // VisibleForTesting
+    File getCurrentTzDataDir() {
+        return currentTzDataDir;
+    }
+
+    // VisibleForTesting
+    File getWorkingDir() {
+        return workingDir;
+    }
+
+    /**
+     * Stage an install of the supplied content, to be installed the next time the device boots.
+     *
+     * <p>Errors during unpacking or staging will throw an {@link IOException}.
+     * If the distro content is invalid this method returns {@code false}.
+     * If the installation completed successfully this method returns {@code true}.
+     */
+    public boolean install(TimeZoneDistro distro) throws IOException {
+        int result = stageInstallWithErrorCode(distro);
+        return result == INSTALL_SUCCESS;
+    }
+
+    /**
+     * Stage an install of the supplied content, to be installed the next time the device boots.
+     *
+     * <p>Errors during unpacking or staging will throw an {@link IOException}.
+     * Returns {@link #INSTALL_SUCCESS} or an error code.
+     */
+    public int stageInstallWithErrorCode(TimeZoneDistro distro) throws IOException {
+        if (oldStagedDataDir.exists()) {
+            FileUtils.deleteRecursive(oldStagedDataDir);
+        }
+        if (workingDir.exists()) {
+            FileUtils.deleteRecursive(workingDir);
+        }
+
+        Slog.i(logTag, "Unpacking / verifying time zone update");
+        try {
+            unpackDistro(distro, workingDir);
+
+            DistroVersion distroVersion;
+            try {
+                distroVersion = readDistroVersion(workingDir);
+            } catch (DistroException e) {
+                Slog.i(logTag, "Invalid distro version: " + e.getMessage());
+                return INSTALL_FAIL_BAD_DISTRO_STRUCTURE;
+            }
+            if (distroVersion == null) {
+                Slog.i(logTag, "Update not applied: Distro version could not be loaded");
+                return INSTALL_FAIL_BAD_DISTRO_STRUCTURE;
+            }
+            if (!DistroVersion.isCompatibleWithThisDevice(distroVersion)) {
+                Slog.i(logTag, "Update not applied: Distro format version check failed: "
+                        + distroVersion);
+                return INSTALL_FAIL_BAD_DISTRO_FORMAT_VERSION;
+            }
+
+            if (!checkDistroDataFilesExist(workingDir)) {
+                Slog.i(logTag, "Update not applied: Distro is missing required data file(s)");
+                return INSTALL_FAIL_BAD_DISTRO_STRUCTURE;
+            }
+
+            if (!checkDistroRulesNewerThanSystem(systemTzDataFile, distroVersion)) {
+                Slog.i(logTag, "Update not applied: Distro rules version check failed");
+                return INSTALL_FAIL_RULES_TOO_OLD;
+            }
+
+            // Validate the tzdata file.
+            File zoneInfoFile = new File(workingDir, TimeZoneDistro.TZDATA_FILE_NAME);
+            ZoneInfoDB.TzData tzData = ZoneInfoDB.TzData.loadTzData(zoneInfoFile.getPath());
+            if (tzData == null) {
+                Slog.i(logTag, "Update not applied: " + zoneInfoFile + " could not be loaded");
+                return INSTALL_FAIL_VALIDATION_ERROR;
+            }
+            try {
+                tzData.validate();
+            } catch (IOException e) {
+                Slog.i(logTag, "Update not applied: " + zoneInfoFile + " failed validation", e);
+                return INSTALL_FAIL_VALIDATION_ERROR;
+            } finally {
+                tzData.close();
+            }
+
+            // Validate the tzlookup.xml file.
+            File tzLookupFile = new File(workingDir, TimeZoneDistro.TZLOOKUP_FILE_NAME);
+            if (!tzLookupFile.exists()) {
+                Slog.i(logTag, "Update not applied: " + tzLookupFile + " does not exist");
+                return INSTALL_FAIL_BAD_DISTRO_STRUCTURE;
+            }
+            try {
+                TimeZoneFinder timeZoneFinder =
+                        TimeZoneFinder.createInstance(tzLookupFile.getPath());
+                timeZoneFinder.validate();
+            } catch (IOException e) {
+                Slog.i(logTag, "Update not applied: " + tzLookupFile + " failed validation", e);
+                return INSTALL_FAIL_VALIDATION_ERROR;
+            }
+
+            // TODO(nfuller): Add validity checks for ICU data / canarying before applying.
+            // http://b/31008728
+
+            Slog.i(logTag, "Applying time zone update");
+            FileUtils.makeDirectoryWorldAccessible(workingDir);
+
+            // Check if there is already a staged install or uninstall and remove it if there is.
+            if (!stagedTzDataDir.exists()) {
+                Slog.i(logTag, "Nothing to unstage at " + stagedTzDataDir);
+            } else {
+                Slog.i(logTag, "Moving " + stagedTzDataDir + " to " + oldStagedDataDir);
+                // Move stagedTzDataDir out of the way in one operation so we can't partially delete
+                // the contents.
+                FileUtils.rename(stagedTzDataDir, oldStagedDataDir);
+            }
+
+            // Move the workingDir to be the new staged directory.
+            Slog.i(logTag, "Moving " + workingDir + " to " + stagedTzDataDir);
+            FileUtils.rename(workingDir, stagedTzDataDir);
+            Slog.i(logTag, "Install staged: " + stagedTzDataDir + " successfully created");
+            return INSTALL_SUCCESS;
+        } finally {
+            deleteBestEffort(oldStagedDataDir);
+            deleteBestEffort(workingDir);
+        }
+    }
+
+    /**
+     * Stage an uninstall of the current timezone update in /data which, on reboot, will return the
+     * device to using data from /system. Returns {@code true} if staging the uninstallation was
+     * successful, {@code false} if there was nothing installed in /data to uninstall. If there was
+     * something else staged it will be replaced by this call.
+     *
+     * <p>Errors encountered during uninstallation will throw an {@link IOException}.
+     */
+    public boolean stageUninstall() throws IOException {
+        Slog.i(logTag, "Uninstalling time zone update");
+
+        if (oldStagedDataDir.exists()) {
+            // If we can't remove this, an exception is thrown and we don't continue.
+            FileUtils.deleteRecursive(oldStagedDataDir);
+        }
+        if (workingDir.exists()) {
+            FileUtils.deleteRecursive(workingDir);
+        }
+
+        try {
+            // Check if there is already an install or uninstall staged and remove it.
+            if (!stagedTzDataDir.exists()) {
+                Slog.i(logTag, "Nothing to unstage at " + stagedTzDataDir);
+            } else {
+                Slog.i(logTag, "Moving " + stagedTzDataDir + " to " + oldStagedDataDir);
+                // Move stagedTzDataDir out of the way in one operation so we can't partially delete
+                // the contents.
+                FileUtils.rename(stagedTzDataDir, oldStagedDataDir);
+            }
+
+            // If there's nothing actually installed, there's nothing to uninstall so no need to
+            // stage anything.
+            if (!currentTzDataDir.exists()) {
+                Slog.i(logTag, "Nothing to uninstall at " + currentTzDataDir);
+                return false;
+            }
+
+            // Stage an uninstall in workingDir.
+            FileUtils.ensureDirectoriesExist(workingDir, true /* makeWorldReadable */);
+            FileUtils.createEmptyFile(new File(workingDir, UNINSTALL_TOMBSTONE_FILE_NAME));
+
+            // Move the workingDir to be the new staged directory.
+            Slog.i(logTag, "Moving " + workingDir + " to " + stagedTzDataDir);
+            FileUtils.rename(workingDir, stagedTzDataDir);
+            Slog.i(logTag, "Uninstall staged: " + stagedTzDataDir + " successfully created");
+
+            return true;
+        } finally {
+            deleteBestEffort(oldStagedDataDir);
+            deleteBestEffort(workingDir);
+        }
+    }
+
+    /**
+     * Reads the currently installed distro version. Returns {@code null} if there is no distro
+     * installed.
+     *
+     * @throws IOException if there was a problem reading data from /data
+     * @throws DistroException if there was a problem with the installed distro format/structure
+     */
+    public DistroVersion getInstalledDistroVersion() throws DistroException, IOException {
+        if (!currentTzDataDir.exists()) {
+            return null;
+        }
+        return readDistroVersion(currentTzDataDir);
+    }
+
+    /**
+     * Reads information about any currently staged distro operation. Returns {@code null} if there
+     * is no distro operation staged.
+     *
+     * @throws IOException if there was a problem reading data from /data
+     * @throws DistroException if there was a problem with the staged distro format/structure
+     */
+    public StagedDistroOperation getStagedDistroOperation() throws DistroException, IOException {
+        if (!stagedTzDataDir.exists()) {
+            return null;
+        }
+        if (new File(stagedTzDataDir, UNINSTALL_TOMBSTONE_FILE_NAME).exists()) {
+            return StagedDistroOperation.uninstall();
+        } else {
+            return StagedDistroOperation.install(readDistroVersion(stagedTzDataDir));
+        }
+    }
+
+    /**
+     * Reads the timezone rules version present in /system. i.e. the version that would be present
+     * after a factory reset.
+     *
+     * @throws IOException if there was a problem reading data
+     */
+    public String getSystemRulesVersion() throws IOException {
+        return readSystemRulesVersion(systemTzDataFile);
+    }
+
+    private void deleteBestEffort(File dir) {
+        if (dir.exists()) {
+            Slog.i(logTag, "Deleting " + dir);
+            try {
+                FileUtils.deleteRecursive(dir);
+            } catch (IOException e) {
+                // Logged but otherwise ignored.
+                Slog.w(logTag, "Unable to delete " + dir, e);
+            }
+        }
+    }
+
+    private void unpackDistro(TimeZoneDistro distro, File targetDir) throws IOException {
+        Slog.i(logTag, "Unpacking update content to: " + targetDir);
+        distro.extractTo(targetDir);
+    }
+
+    private boolean checkDistroDataFilesExist(File unpackedContentDir) throws IOException {
+        Slog.i(logTag, "Verifying distro contents");
+        return FileUtils.filesExist(unpackedContentDir,
+                TimeZoneDistro.TZDATA_FILE_NAME,
+                TimeZoneDistro.ICU_DATA_FILE_NAME);
+    }
+
+    private DistroVersion readDistroVersion(File distroDir) throws DistroException, IOException {
+        Slog.i(logTag, "Reading distro format version: " + distroDir);
+        File distroVersionFile = new File(distroDir, TimeZoneDistro.DISTRO_VERSION_FILE_NAME);
+        if (!distroVersionFile.exists()) {
+            throw new DistroException("No distro version file found: " + distroVersionFile);
+        }
+        byte[] versionBytes =
+                FileUtils.readBytes(distroVersionFile, DistroVersion.DISTRO_VERSION_FILE_LENGTH);
+        return DistroVersion.fromBytes(versionBytes);
+    }
+
+    /**
+     * Returns true if the the distro IANA rules version is >= system IANA rules version.
+     */
+    private boolean checkDistroRulesNewerThanSystem(
+            File systemTzDataFile, DistroVersion distroVersion) throws IOException {
+
+        // We only check the /system tzdata file and assume that other data like ICU is in sync.
+        // There is a CTS test that checks ICU and bionic/libcore are in sync.
+        Slog.i(logTag, "Reading /system rules version");
+        String systemRulesVersion = readSystemRulesVersion(systemTzDataFile);
+
+        String distroRulesVersion = distroVersion.rulesVersion;
+        // canApply = distroRulesVersion >= systemRulesVersion
+        boolean canApply = distroRulesVersion.compareTo(systemRulesVersion) >= 0;
+        if (!canApply) {
+            Slog.i(logTag, "Failed rules version check: distroRulesVersion="
+                    + distroRulesVersion + ", systemRulesVersion=" + systemRulesVersion);
+        } else {
+            Slog.i(logTag, "Passed rules version check: distroRulesVersion="
+                    + distroRulesVersion + ", systemRulesVersion=" + systemRulesVersion);
+        }
+        return canApply;
+    }
+
+    private String readSystemRulesVersion(File systemTzDataFile) throws IOException {
+        if (!systemTzDataFile.exists()) {
+            Slog.i(logTag, "tzdata file cannot be found in /system");
+            throw new FileNotFoundException("system tzdata does not exist: " + systemTzDataFile);
+        }
+        return ZoneInfoDB.TzData.getRulesVersion(systemTzDataFile);
+    }
+}
diff --git a/distro/installer/src/test/com/android/timezone/distro/installer/TimeZoneDistroInstallerTest.java b/distro/installer/src/test/com/android/timezone/distro/installer/TimeZoneDistroInstallerTest.java
new file mode 100644
index 0000000..596b6ff
--- /dev/null
+++ b/distro/installer/src/test/com/android/timezone/distro/installer/TimeZoneDistroInstallerTest.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright (C) 2015 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.timezone.distro.installer;
+
+import com.android.timezone.distro.DistroVersion;
+import com.android.timezone.distro.FileUtils;
+import com.android.timezone.distro.StagedDistroOperation;
+import com.android.timezone.distro.TimeZoneDistro;
+import com.android.timezone.distro.tools.TimeZoneDistroBuilder;
+
+import junit.framework.TestCase;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.FileVisitor;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import libcore.io.IoUtils;
+import libcore.tzdata.testing.ZoneInfoTestHelper;
+
+import static org.junit.Assert.assertArrayEquals;
+
+/**
+ * Tests for {@link TimeZoneDistroInstaller}.
+ */
+public class TimeZoneDistroInstallerTest extends TestCase {
+
+    // OLDER_RULES_VERSION < SYSTEM_RULES_VERSION < NEW_RULES_VERSION < NEWER_RULES_VERSION
+    private static final String OLDER_RULES_VERSION = "2030a";
+    private static final String SYSTEM_RULES_VERSION = "2030b";
+    private static final String NEW_RULES_VERSION = "2030c";
+    private static final String NEWER_RULES_VERSION = "2030d";
+
+    private TimeZoneDistroInstaller installer;
+    private File tempDir;
+    private File testInstallDir;
+    private File testSystemTzDataDir;
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        tempDir = createUniqueDirectory(null, "tempDir");
+        testInstallDir = createSubDirectory(tempDir, "testInstall");
+        testSystemTzDataDir =  createSubDirectory(tempDir, "testSystemTzData");
+
+        // Create a file to represent the tzdata file in the /system partition of the device.
+        File testSystemTzDataFile = new File(testSystemTzDataDir, "tzdata");
+        byte[] systemTzDataBytes = createTzData(SYSTEM_RULES_VERSION);
+        createFile(testSystemTzDataFile, systemTzDataBytes);
+
+        installer = new TimeZoneDistroInstaller(
+                "TimeZoneDistroInstallerTest", testSystemTzDataFile, testInstallDir);
+    }
+
+    /**
+     * Creates a unique temporary directory. rootDir can be null, in which case the directory will
+     * be created beneath the directory pointed to by the java.io.tmpdir system property.
+     */
+    private static File createUniqueDirectory(File rootDir, String prefix) throws Exception {
+        File dir = File.createTempFile(prefix, "", rootDir);
+        assertTrue(dir.delete());
+        assertTrue(dir.mkdir());
+        return dir;
+    }
+
+    private static File createSubDirectory(File parent, String subDirName) {
+        File dir = new File(parent, subDirName);
+        assertTrue(dir.mkdir());
+        return dir;
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        if (tempDir.exists()) {
+            FileUtils.deleteRecursive(tempDir);
+        }
+        super.tearDown();
+    }
+
+    /** Tests the an update on a device will fail if the /system tzdata file cannot be found. */
+    public void testStageInstallWithErrorCode_badSystemFile() throws Exception {
+        File doesNotExist = new File(testSystemTzDataDir, "doesNotExist");
+        TimeZoneDistroInstaller brokenSystemInstaller = new TimeZoneDistroInstaller(
+                "TimeZoneDistroInstallerTest", doesNotExist, testInstallDir);
+        byte[] distroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+
+        try {
+            brokenSystemInstaller.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes));
+            fail();
+        } catch (IOException expected) {}
+
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    /** Tests the first successful update on a device */
+    public void testStageInstallWithErrorCode_successfulFirstUpdate() throws Exception {
+        byte[] distroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes)));
+        assertInstallDistroStaged(distroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests we can install an update the same version as is in /system.
+     */
+    public void testStageInstallWithErrorCode_successfulFirstUpdate_sameVersionAsSystem()
+            throws Exception {
+        byte[] distroBytes = createValidTimeZoneDistroBytes(SYSTEM_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes)));
+        assertInstallDistroStaged(distroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests we cannot install an update older than the version in /system.
+     */
+    public void testStageInstallWithErrorCode_unsuccessfulFirstUpdate_olderVersionThanSystem()
+            throws Exception {
+        byte[] distroBytes = createValidTimeZoneDistroBytes(OLDER_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_RULES_TOO_OLD,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes)));
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests an update on a device when there is a prior update already staged.
+     */
+    public void testStageInstallWithErrorCode_successfulFollowOnUpdate_newerVersion()
+            throws Exception {
+        byte[] distro1Bytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro1Bytes)));
+        assertInstallDistroStaged(distro1Bytes);
+
+        byte[] distro2Bytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 2);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro2Bytes)));
+        assertInstallDistroStaged(distro2Bytes);
+
+        byte[] distro3Bytes = createValidTimeZoneDistroBytes(NEWER_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro3Bytes)));
+        assertInstallDistroStaged(distro3Bytes);
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests an update on a device when there is a prior update already applied, but the follow
+     * on update is older than in /system.
+     */
+    public void testStageInstallWithErrorCode_unsuccessfulFollowOnUpdate_olderVersion()
+            throws Exception {
+        byte[] distro1Bytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 2);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro1Bytes)));
+        assertInstallDistroStaged(distro1Bytes);
+
+        byte[] distro2Bytes = createValidTimeZoneDistroBytes(OLDER_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_RULES_TOO_OLD,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro2Bytes)));
+        assertInstallDistroStaged(distro1Bytes);
+        assertNoInstalledDistro();
+    }
+
+    /** Tests that a distro with a missing tzdata file will not update the content. */
+    public void testStageInstallWithErrorCode_missingTzDataFile() throws Exception {
+        byte[] stagedDistroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(stagedDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+
+        byte[] incompleteDistroBytes =
+                createValidTimeZoneDistroBuilder(NEWER_RULES_VERSION, 1)
+                        .clearTzDataForTests()
+                        .buildUnvalidatedBytes();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(incompleteDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /** Tests that a distro with a missing ICU file will not update the content. */
+    public void testStageInstallWithErrorCode_missingIcuFile() throws Exception {
+        byte[] stagedDistroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(stagedDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+
+        byte[] incompleteDistroBytes =
+                createValidTimeZoneDistroBuilder(NEWER_RULES_VERSION, 1)
+                        .clearIcuDataForTests()
+                        .buildUnvalidatedBytes();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(incompleteDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /** Tests that a distro with a missing tzlookup file will not update the content. */
+    public void testStageInstallWithErrorCode_missingTzLookupFile() throws Exception {
+        byte[] stagedDistroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(stagedDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+
+        byte[] incompleteDistroBytes =
+                createValidTimeZoneDistroBuilder(NEWER_RULES_VERSION, 1)
+                        .setTzLookupXml(null)
+                        .buildUnvalidatedBytes();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(incompleteDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /** Tests that a distro with a bad tzlookup file will not update the content. */
+    public void testStageInstallWithErrorCode_badTzLookupFile() throws Exception {
+        byte[] stagedDistroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(stagedDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+
+        byte[] incompleteDistroBytes =
+                createValidTimeZoneDistroBuilder(NEWER_RULES_VERSION, 1)
+                        .setTzLookupXml("<foo />")
+                        .buildUnvalidatedBytes();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_VALIDATION_ERROR,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(incompleteDistroBytes)));
+        assertInstallDistroStaged(stagedDistroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests that an update will be unpacked even if there is a partial update from a previous run.
+     */
+    public void testStageInstallWithErrorCode_withWorkingDir() throws Exception {
+        File workingDir = installer.getWorkingDir();
+        assertTrue(workingDir.mkdir());
+        createFile(new File(workingDir, "myFile"), new byte[] { 'a' });
+
+        byte[] distroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes)));
+        assertInstallDistroStaged(distroBytes);
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests that a distro without a distro version file will be rejected.
+     */
+    public void testStageInstallWithErrorCode_withMissingDistroVersionFile() throws Exception {
+        // Create a distro without a version file.
+        byte[] distroBytes = createValidTimeZoneDistroBuilder(NEW_RULES_VERSION, 1)
+                .clearVersionForTests()
+                .buildUnvalidatedBytes();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes)));
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests that a distro with an newer distro version will be rejected.
+     */
+    public void testStageInstallWithErrorCode_withNewerDistroVersion() throws Exception {
+        // Create a distro that will appear to be newer than the one currently supported.
+        byte[] distroBytes = createValidTimeZoneDistroBuilder(NEW_RULES_VERSION, 1)
+                .replaceFormatVersionForTests(2, 1)
+                .buildUnvalidatedBytes();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_FORMAT_VERSION,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distroBytes)));
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests that a distro with a badly formed distro version will be rejected.
+     */
+    public void testStageInstallWithErrorCode_withBadlyFormedDistroVersion() throws Exception {
+        // Create a distro that has an invalid major distro version. It should be 3 numeric
+        // characters, "." and 3 more numeric characters.
+        DistroVersion validDistroVersion = new DistroVersion(1, 1, NEW_RULES_VERSION, 1);
+        byte[] invalidFormatVersionBytes = validDistroVersion.toBytes();
+        invalidFormatVersionBytes[0] = 'A';
+
+        TimeZoneDistro distro = createTimeZoneDistroWithVersionBytes(invalidFormatVersionBytes);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(distro));
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests that a distro with a badly formed revision will be rejected.
+     */
+    public void testStageInstallWithErrorCode_withBadlyFormedRevision() throws Exception {
+        // Create a distro that has an invalid revision. It should be 3 numeric characters.
+        DistroVersion validDistroVersion = new DistroVersion(1, 1, NEW_RULES_VERSION, 1);
+        byte[] invalidRevisionBytes = validDistroVersion.toBytes();
+        invalidRevisionBytes[invalidRevisionBytes.length - 3] = 'A';
+
+        TimeZoneDistro distro = createTimeZoneDistroWithVersionBytes(invalidRevisionBytes);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(distro));
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    /**
+     * Tests that a distro with a badly formed rules version will be rejected.
+     */
+    public void testStageInstallWithErrorCode_withBadlyFormedRulesVersion() throws Exception {
+        // Create a distro that has an invalid rules version. It should be in the form "2016c".
+        DistroVersion validDistroVersion = new DistroVersion(1, 1, NEW_RULES_VERSION, 1);
+        byte[] invalidRulesVersionBytes = validDistroVersion.toBytes();
+        invalidRulesVersionBytes[invalidRulesVersionBytes.length - 6] = 'B';
+
+        TimeZoneDistro distro = createTimeZoneDistroWithVersionBytes(invalidRulesVersionBytes);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(distro));
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    public void testStageUninstall_noExistingDistro() throws Exception {
+        // To stage an uninstall, there would need to be installed rules.
+        assertFalse(installer.stageUninstall());
+
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+    }
+
+    public void testStageUninstall_existingStagedDataDistro() throws Exception {
+        // To stage an uninstall, we need to have some installed rules.
+        byte[] installedDistroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        simulateInstalledDistro(installedDistroBytes);
+
+        File stagedDataDir = installer.getStagedTzDataDir();
+        assertTrue(stagedDataDir.mkdir());
+
+        assertTrue(installer.stageUninstall());
+        assertDistroUninstallStaged();
+        assertInstalledDistro(installedDistroBytes);
+    }
+
+    public void testStageUninstall_oldDirsAlreadyExists() throws Exception {
+        // To stage an uninstall, we need to have some installed rules.
+        byte[] installedDistroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        simulateInstalledDistro(installedDistroBytes);
+
+        File oldStagedDataDir = installer.getOldStagedDataDir();
+        assertTrue(oldStagedDataDir.mkdir());
+
+        File workingDir = installer.getWorkingDir();
+        assertTrue(workingDir.mkdir());
+
+        assertTrue(installer.stageUninstall());
+
+        assertDistroUninstallStaged();
+        assertFalse(workingDir.exists());
+        assertFalse(oldStagedDataDir.exists());
+        assertInstalledDistro(installedDistroBytes);
+    }
+
+    public void testGetSystemRulesVersion() throws Exception {
+        assertEquals(SYSTEM_RULES_VERSION, installer.getSystemRulesVersion());
+    }
+
+    public void testGetInstalledDistroVersion() throws Exception {
+        // Check result when nothing installed.
+        assertNull(installer.getInstalledDistroVersion());
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+
+        // Now simulate there being an existing install active.
+        byte[] distroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        simulateInstalledDistro(distroBytes);
+        assertInstalledDistro(distroBytes);
+
+        // Check result when something installed.
+        assertEquals(new TimeZoneDistro(distroBytes).getDistroVersion(),
+                installer.getInstalledDistroVersion());
+        assertNoDistroOperationStaged();
+        assertInstalledDistro(distroBytes);
+    }
+
+    public void testGetStagedDistroOperation() throws Exception {
+        byte[] distro1Bytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        byte[] distro2Bytes = createValidTimeZoneDistroBytes(NEWER_RULES_VERSION, 1);
+
+        // Check result when nothing staged.
+        assertNull(installer.getStagedDistroOperation());
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+
+        // Check result after unsuccessfully staging an uninstall.
+        // Can't stage an uninstall without an installed distro.
+        assertFalse(installer.stageUninstall());
+        assertNull(installer.getStagedDistroOperation());
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+
+        // Check result after staging an install.
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro1Bytes)));
+        StagedDistroOperation expectedStagedInstall =
+                StagedDistroOperation.install(new TimeZoneDistro(distro1Bytes).getDistroVersion());
+        assertEquals(expectedStagedInstall, installer.getStagedDistroOperation());
+        assertInstallDistroStaged(distro1Bytes);
+        assertNoInstalledDistro();
+
+        // Check result after unsuccessfully staging an uninstall (but after removing a staged
+        // install). Can't stage an uninstall without an installed distro.
+        assertFalse(installer.stageUninstall());
+        assertNull(installer.getStagedDistroOperation());
+        assertNoDistroOperationStaged();
+        assertNoInstalledDistro();
+
+        // Now simulate there being an existing install active.
+        simulateInstalledDistro(distro1Bytes);
+        assertInstalledDistro(distro1Bytes);
+
+        // Check state after successfully staging an uninstall.
+        assertTrue(installer.stageUninstall());
+        StagedDistroOperation expectedStagedUninstall = StagedDistroOperation.uninstall();
+        assertEquals(expectedStagedUninstall, installer.getStagedDistroOperation());
+        assertDistroUninstallStaged();
+        assertInstalledDistro(distro1Bytes);
+
+        // Check state after successfully staging an install.
+        assertEquals(TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(new TimeZoneDistro(distro2Bytes)));
+        StagedDistroOperation expectedStagedInstall2 =
+                StagedDistroOperation.install(new TimeZoneDistro(distro2Bytes).getDistroVersion());
+        assertEquals(expectedStagedInstall2, installer.getStagedDistroOperation());
+        assertInstallDistroStaged(distro2Bytes);
+        assertInstalledDistro(distro1Bytes);
+    }
+
+    private static byte[] createValidTimeZoneDistroBytes(
+            String rulesVersion, int revision) throws Exception {
+        return createValidTimeZoneDistroBuilder(rulesVersion, revision).buildBytes();
+    }
+
+    private static TimeZoneDistroBuilder createValidTimeZoneDistroBuilder(
+            String rulesVersion, int revision) throws Exception {
+
+        byte[] tzData = createTzData(rulesVersion);
+        byte[] icuData = new byte[] { 'a' };
+        String tzlookupXml = "<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"us\">\n"
+                + "      <id>America/New_York\"</id>\n"
+                + "      <id>America/Los_Angeles</id>\n"
+                + "    </country>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n";
+        DistroVersion distroVersion = new DistroVersion(
+                DistroVersion.CURRENT_FORMAT_MAJOR_VERSION,
+                DistroVersion.CURRENT_FORMAT_MINOR_VERSION,
+                rulesVersion,
+                revision);
+        return new TimeZoneDistroBuilder()
+                .setDistroVersion(distroVersion)
+                .setTzDataFile(tzData)
+                .setIcuDataFile(icuData)
+                .setTzLookupXml(tzlookupXml);
+    }
+
+    private void assertInstallDistroStaged(byte[] expectedDistroBytes) throws Exception {
+        assertTrue(testInstallDir.exists());
+
+        File stagedTzDataDir = installer.getStagedTzDataDir();
+        assertTrue(stagedTzDataDir.exists());
+
+        File distroVersionFile =
+                new File(stagedTzDataDir, TimeZoneDistro.DISTRO_VERSION_FILE_NAME);
+        assertTrue(distroVersionFile.exists());
+
+        File tzdataFile = new File(stagedTzDataDir, TimeZoneDistro.TZDATA_FILE_NAME);
+        assertTrue(tzdataFile.exists());
+
+        File icuFile = new File(stagedTzDataDir, TimeZoneDistro.ICU_DATA_FILE_NAME);
+        assertTrue(icuFile.exists());
+
+        File tzLookupFile = new File(stagedTzDataDir, TimeZoneDistro.TZLOOKUP_FILE_NAME);
+        assertTrue(tzLookupFile.exists());
+
+        // Assert getStagedDistroState() is reporting correctly.
+        StagedDistroOperation stagedDistroOperation = installer.getStagedDistroOperation();
+        assertNotNull(stagedDistroOperation);
+        assertFalse(stagedDistroOperation.isUninstall);
+        assertEquals(new TimeZoneDistro(expectedDistroBytes).getDistroVersion(),
+                stagedDistroOperation.distroVersion);
+
+        File expectedZipContentDir = createUniqueDirectory(tempDir, "expectedZipContent");
+        new TimeZoneDistro(expectedDistroBytes).extractTo(expectedZipContentDir);
+
+        assertContentsMatches(
+                new File(expectedZipContentDir, TimeZoneDistro.DISTRO_VERSION_FILE_NAME),
+                distroVersionFile);
+        assertContentsMatches(
+                new File(expectedZipContentDir, TimeZoneDistro.ICU_DATA_FILE_NAME),
+                icuFile);
+        assertContentsMatches(
+                new File(expectedZipContentDir, TimeZoneDistro.TZDATA_FILE_NAME),
+                tzdataFile);
+        assertContentsMatches(
+                new File(expectedZipContentDir, TimeZoneDistro.TZLOOKUP_FILE_NAME),
+                tzLookupFile);
+        assertFileCount(4, expectedZipContentDir);
+
+        // Also check no working directory is left lying around.
+        File workingDir = installer.getWorkingDir();
+        assertFalse(workingDir.exists());
+    }
+
+    private static void assertFileCount(int expectedFiles, File rootDir) throws Exception {
+        final List<Path> paths = new ArrayList<>();
+        FileVisitor<Path> visitor = new SimpleFileVisitor<Path>() {
+            @Override
+            public FileVisitResult visitFile(Path filePath, BasicFileAttributes attrs)
+                        throws IOException {
+                paths.add(filePath);
+                return FileVisitResult.CONTINUE;
+            }
+        };
+        Files.walkFileTree(rootDir.toPath(), visitor);
+        assertEquals("Found: " + paths, expectedFiles, paths.size());
+    }
+
+    private void assertContentsMatches(File expected, File actual) throws IOException {
+        byte[] actualBytes = IoUtils.readFileAsByteArray(actual.getPath());
+        byte[] expectedBytes = IoUtils.readFileAsByteArray(expected.getPath());
+        assertArrayEquals(expectedBytes, actualBytes);
+    }
+
+    private void assertNoDistroOperationStaged() throws Exception {
+        assertNull(installer.getStagedDistroOperation());
+
+        File stagedTzDataDir = installer.getStagedTzDataDir();
+        assertFalse(stagedTzDataDir.exists());
+
+        // Also check no working directories are left lying around.
+        File workingDir = installer.getWorkingDir();
+        assertFalse(workingDir.exists());
+
+        File oldDataDir = installer.getOldStagedDataDir();
+        assertFalse(oldDataDir.exists());
+    }
+
+    private void assertDistroUninstallStaged() throws Exception {
+        assertEquals(StagedDistroOperation.uninstall(), installer.getStagedDistroOperation());
+
+        File stagedTzDataDir = installer.getStagedTzDataDir();
+        assertTrue(stagedTzDataDir.exists());
+        assertTrue(stagedTzDataDir.isDirectory());
+
+        File uninstallTombstone =
+                new File(stagedTzDataDir, TimeZoneDistroInstaller.UNINSTALL_TOMBSTONE_FILE_NAME);
+        assertTrue(uninstallTombstone.exists());
+        assertTrue(uninstallTombstone.isFile());
+
+        // Also check no working directories are left lying around.
+        File workingDir = installer.getWorkingDir();
+        assertFalse(workingDir.exists());
+
+        File oldDataDir = installer.getOldStagedDataDir();
+        assertFalse(oldDataDir.exists());
+    }
+
+    private void simulateInstalledDistro(byte[] distroBytes) throws Exception {
+        File currentTzDataDir = installer.getCurrentTzDataDir();
+        assertFalse(currentTzDataDir.exists());
+        assertTrue(currentTzDataDir.mkdir());
+        new TimeZoneDistro(distroBytes).extractTo(currentTzDataDir);
+    }
+
+    private void assertNoInstalledDistro() {
+        assertFalse(installer.getCurrentTzDataDir().exists());
+    }
+
+    private void assertInstalledDistro(byte[] distroBytes) throws Exception {
+        File currentTzDataDir = installer.getCurrentTzDataDir();
+        assertTrue(currentTzDataDir.exists());
+        File versionFile = new File(currentTzDataDir, TimeZoneDistro.DISTRO_VERSION_FILE_NAME);
+        assertTrue(versionFile.exists());
+        byte[] expectedVersionBytes = new TimeZoneDistro(distroBytes).getDistroVersion().toBytes();
+        byte[] actualVersionBytes = FileUtils.readBytes(versionFile, expectedVersionBytes.length);
+        assertArrayEquals(expectedVersionBytes, actualVersionBytes);
+    }
+
+    private static byte[] createTzData(String rulesVersion) {
+        return new ZoneInfoTestHelper.TzDataBuilder()
+                .initializeToValid()
+                .setHeaderMagic("tzdata" + rulesVersion)
+                .build();
+    }
+
+    private static void createFile(File file, byte[] bytes) {
+        try (FileOutputStream fos = new FileOutputStream(file)) {
+            fos.write(bytes);
+        } catch (IOException e) {
+            fail(e.getMessage());
+        }
+    }
+
+    /**
+     * Creates a TimeZoneDistro containing arbitrary bytes in the version file. Used for testing
+     * distros with badly formed version info.
+     */
+    private TimeZoneDistro createTimeZoneDistroWithVersionBytes(byte[] versionBytes)
+            throws Exception {
+
+        // Extract a valid distro to a working dir.
+        byte[] distroBytes = createValidTimeZoneDistroBytes(NEW_RULES_VERSION, 1);
+        File workingDir = createUniqueDirectory(tempDir, "versionBytes");
+        new TimeZoneDistro(distroBytes).extractTo(workingDir);
+
+        // Modify the version file.
+        File versionFile = new File(workingDir, TimeZoneDistro.DISTRO_VERSION_FILE_NAME);
+        assertTrue(versionFile.exists());
+        try (FileOutputStream fos = new FileOutputStream(versionFile, false /* append */)) {
+            fos.write(versionBytes);
+        }
+
+        // Zip the distro back up again.
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (ZipOutputStream zos = new ZipOutputStream(baos)) {
+            Path workingDirPath = workingDir.toPath();
+            Files.walkFileTree(workingDirPath, new SimpleFileVisitor<Path>() {
+                @Override
+                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+                        throws IOException {
+                    byte[] bytes = IoUtils.readFileAsByteArray(file.toString());
+                    String relativeFileName = workingDirPath.relativize(file).toString();
+                    addZipEntry(zos, relativeFileName, bytes);
+                    return FileVisitResult.CONTINUE;
+                }
+            });
+        }
+
+        return new TimeZoneDistro(baos.toByteArray());
+    }
+
+    private static void addZipEntry(ZipOutputStream zos, String name, byte[] content)
+            throws IOException {
+        ZipEntry zipEntry = new ZipEntry(name);
+        zipEntry.setSize(content.length);
+        zos.putNextEntry(zipEntry);
+        zos.write(content);
+        zos.closeEntry();
+    }
+}