diff options
author | Neil Fuller <nfuller@google.com> | 2015-03-13 14:31:20 +0000 |
---|---|---|
committer | Neil Fuller <nfuller@google.com> | 2015-03-31 09:28:06 +0100 |
commit | 91c98d778c80e53a7f458264233375f982dcae14 (patch) | |
tree | 6d048b60f46c805e8a701e3af9798a4b1850a0c5 /tzdata/update/src/main | |
parent | 1e342670cc46445bd51d53f7a28f95d1eb879c9f (diff) | |
download | libcore-91c98d778c80e53a7f458264233375f982dcae14.zip libcore-91c98d778c80e53a7f458264233375f982dcae14.tar.gz libcore-91c98d778c80e53a7f458264233375f982dcae14.tar.bz2 |
Timezone data installer code
The code here is used by the system server to install timezone data updates.
It is separate so it can be tested.
Scripts are included that build an "update bundle" (a zip file with a
well-defined contents).
ConfigBundle contains logic to extract the bundle safely.
TzDataBundleBuilder is used in the test and tools to construct a bundle.
The scripts in tools is a placeholder and will evolve.
bionic/libc/tools/zoneinfo tools will likely move there so they can be
integrated more closely.
An app is included for testing updates.
Bug: 19941636
Change-Id: Id0985f8c5be2f12858ee8bf52acf52bdb2df8741
Diffstat (limited to 'tzdata/update/src/main')
3 files changed, 465 insertions, 0 deletions
diff --git a/tzdata/update/src/main/libcore/tzdata/update/ConfigBundle.java b/tzdata/update/src/main/libcore/tzdata/update/ConfigBundle.java new file mode 100644 index 0000000..6e2ff9d --- /dev/null +++ b/tzdata/update/src/main/libcore/tzdata/update/ConfigBundle.java @@ -0,0 +1,121 @@ +/* + * 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 libcore.tzdata.update; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * A configuration bundle. This is a thin wrapper around some in-memory bytes representing a zip + * archive and logic for its safe extraction. + */ +public final class ConfigBundle { + + /** The name of the file inside the bundle containing the TZ data version. */ + public static final String TZ_DATA_VERSION_FILE_NAME = "tzdata_version"; + + /** The name of the file inside the bundle containing the expected device checksums. */ + public static final String CHECKSUMS_FILE_NAME = "checksums"; + + /** The name of the file inside the bundle containing bionic/libcore TZ data. */ + public static final String ZONEINFO_FILE_NAME = "tzdata"; + + /** The name of the file inside the bundle containing ICU TZ data. */ + public static final String ICU_DATA_FILE_NAME = "icu/icu_tzdata.dat"; + + private static final int BUFFER_SIZE = 8192; + + private final byte[] bytes; + + public ConfigBundle(byte[] bytes) { + this.bytes = bytes; + } + + public byte[] getBundleBytes() { + return bytes; + } + + public void extractTo(File targetDir) throws IOException { + extractZipSafely(new ByteArrayInputStream(bytes), targetDir, true /* makeWorldReadable */); + } + + /** Visible for testing */ + static void extractZipSafely(InputStream is, File targetDir, boolean makeWorldReadable) + throws IOException { + + // Create the extraction dir, if needed. + FileUtils.ensureDirectoriesExist(targetDir, makeWorldReadable); + + try (ZipInputStream zipInputStream = new ZipInputStream(is)) { + byte[] buffer = new byte[BUFFER_SIZE]; + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + // Validate the entry name: make sure the unpacked file will exist beneath the + // targetDir. + String name = entry.getName(); + File entryFile = FileUtils.createSubFile(targetDir, name); + + if (entry.isDirectory()) { + FileUtils.ensureDirectoriesExist(entryFile, makeWorldReadable); + } else { + // Create the path if there was no directory entry. + if (!entryFile.getParentFile().exists()) { + FileUtils.ensureDirectoriesExist( + entryFile.getParentFile(), makeWorldReadable); + } + + try (FileOutputStream fos = new FileOutputStream(entryFile)) { + int count; + while ((count = zipInputStream.read(buffer)) != -1) { + fos.write(buffer, 0, count); + } + // sync to disk + fos.getFD().sync(); + } + // mark entryFile -rw-r--r-- + if (makeWorldReadable) { + FileUtils.makeWorldReadable(entryFile); + } + } + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + ConfigBundle that = (ConfigBundle) o; + + if (!Arrays.equals(bytes, that.bytes)) { + return false; + } + + return true; + } + +} diff --git a/tzdata/update/src/main/libcore/tzdata/update/FileUtils.java b/tzdata/update/src/main/libcore/tzdata/update/FileUtils.java new file mode 100644 index 0000000..8b7da78 --- /dev/null +++ b/tzdata/update/src/main/libcore/tzdata/update/FileUtils.java @@ -0,0 +1,203 @@ +/* + * 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 libcore.tzdata.update; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.zip.CRC32; + +/** + * Utility methods for files operations. + */ +public final class FileUtils { + + private FileUtils() { + } + + /** + * Creates a new {@link java.io.File} from the {@code parentDir} and {@code name}, but only if + * the + * resulting file would exist beneath {@code parentDir}. Useful if {@code name} could contain + * "/../" or symlinks. The returned object has an absolute path. + * + * @throws java.io.IOException + * if the file would not exist beneath {@code parentDir} + */ + public static File createSubFile(File parentDir, String name) throws IOException { + // The subFile must exist beneath parentDir. If name contains "/../" this may not be the + // case so we check. + File subFile = canonicalizeDirPath(new File(parentDir, name)); + if (!subFile.getPath().startsWith(parentDir.getCanonicalPath())) { + throw new IOException(name + " must exist beneath " + parentDir); + } + return subFile; + } + + /** + * Makes sure a directory exists. If it doesn't exist, it is created. Parent directories are + * also created as needed. If {@code makeWorldReadable} is {@code true} the directory's default + * permissions will be set. Even when {@code makeWorldReadable} is {@code true}, only + * directories explicitly created will have their permissions set; existing directories are + * untouched. + * + * @throws IOException + * if the directory or one of its parents did not already exist and could not be created + */ + public static void ensureDirectoriesExist(File dir, boolean makeWorldReadable) + throws IOException { + LinkedList<File> dirs = new LinkedList<>(); + File currentDir = dir; + do { + dirs.addFirst(currentDir); + currentDir = currentDir.getParentFile(); + } while (currentDir != null); + + for (File dirToCheck : dirs) { + if (!dirToCheck.exists()) { + if (!dirToCheck.mkdir()) { + throw new IOException("Unable to create directory: " + dir); + } + if (makeWorldReadable) { + makeDirectoryWorldAccessible(dirToCheck); + } + } else if (!dirToCheck.isDirectory()) { + throw new IOException(dirToCheck + " exists but is not a directory"); + } + } + } + + /** + * Returns a file with all symlinks and relative paths such as "/../" resolved <em>except</em> + * for the base name (the last element of the path). Useful for detecting symlinks. + */ + public static File canonicalizeDirPath(File file) throws IOException { + return new File(file.getParentFile().getCanonicalFile(), file.getName()); + } + + public static void makeDirectoryWorldAccessible(File directory) throws IOException { + if (!directory.isDirectory()) { + throw new IOException(directory + " must be a directory"); + } + makeWorldReadable(directory); + if (!directory.setExecutable(true, false /* ownerOnly */)) { + throw new IOException("Unable to make " + directory + " world-executable"); + } + } + + public static void makeWorldReadable(File file) throws IOException { + if (!file.setReadable(true, false /* ownerOnly */)) { + throw new IOException("Unable to make " + file + " world-readable"); + } + } + + /** + * Calculates the checksum from the contents of a file. + */ + public static long calculateChecksum(File file) throws IOException { + final int BUFFER_SIZE = 8196; + CRC32 crc32 = new CRC32(); + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int count; + while ((count = fis.read(buffer)) != -1) { + crc32.update(buffer, 0, count); + } + } + return crc32.getValue(); + } + + public static void rename(File from, File to) throws IOException { + ensureFileDoesNotExist(to); + if (!from.renameTo(to)) { + throw new IOException("Unable to rename " + from + " to " + to); + } + } + + public static void ensureFileDoesNotExist(File file) throws IOException { + if (file.exists()) { + if (!file.isFile()) { + throw new IOException(file + " is not a file"); + } + doDelete(file); + } + } + + public static void doDelete(File file) throws IOException { + if (!file.delete()) { + throw new IOException("Unable to delete: " + file); + } + } + + public static boolean isSymlink(File file) throws IOException { + return !file.getCanonicalPath().equals(canonicalizeDirPath(file).getPath()); + } + + public static void deleteRecursive(File toDelete) throws IOException { + if (toDelete.isDirectory()) { + for (File file : toDelete.listFiles()) { + if (file.isDirectory() && !FileUtils.isSymlink(file)) { + // The isSymlink() check is important so that we don't delete files in other + // directories: only the symlink itself. + deleteRecursive(file); + } else { + // Delete symlinks to directories or files. + FileUtils.doDelete(file); + } + } + String[] remainingFiles = toDelete.list(); + if (remainingFiles.length != 0) { + throw new IOException("Unable to delete files: " + Arrays + .toString(remainingFiles)); + } + } + FileUtils.doDelete(toDelete); + } + + public static boolean filesExist(File rootDir, String... fileNames) throws IOException { + for (String fileName : fileNames) { + File file = new File(rootDir, fileName); + if (!file.exists()) { + return false; + } + } + return true; + } + + /** + * Read all lines from a UTF-8 encoded file, returning them as a list of strings. + */ + public static List<String> readLines(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + try (BufferedReader fileReader = new BufferedReader( + new InputStreamReader(in, StandardCharsets.UTF_8)); + ) { + List<String> lines = new ArrayList<>(); + String line; + while ((line = fileReader.readLine()) != null) { + lines.add(line); + } + return lines; + } + } +} diff --git a/tzdata/update/src/main/libcore/tzdata/update/TzDataBundleInstaller.java b/tzdata/update/src/main/libcore/tzdata/update/TzDataBundleInstaller.java new file mode 100644 index 0000000..df0b2a7 --- /dev/null +++ b/tzdata/update/src/main/libcore/tzdata/update/TzDataBundleInstaller.java @@ -0,0 +1,141 @@ +/* + * 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 libcore.tzdata.update; + +import android.util.Slog; + +import java.io.File; +import java.io.IOException; + +/** + * A bundle-validation / extraction class. Separate from the services code that uses it for easier + * testing. + */ +public final class TzDataBundleInstaller { + + static final String CURRENT_TZ_DATA_DIR_NAME = "current"; + static final String WORKING_DIR_NAME = "working"; + static final String OLD_TZ_DATA_DIR_NAME = "old"; + + private final String logTag; + private final File installDir; + + public TzDataBundleInstaller(String logTag, File installDir) { + this.logTag = logTag; + this.installDir = installDir; + } + + /** + * Install the supplied content. + * + * <p>Errors during unpacking or installation will throw an {@link IOException}. + * If the content is invalid this method returns {@code false}. + * If the installation completed successfully this method returns {@code true}. + */ + public boolean install(byte[] content) throws IOException { + File oldTzDataDir = new File(installDir, OLD_TZ_DATA_DIR_NAME); + if (oldTzDataDir.exists()) { + FileUtils.deleteRecursive(oldTzDataDir); + } + + File currentTzDataDir = new File(installDir, CURRENT_TZ_DATA_DIR_NAME); + File workingDir = new File(installDir, WORKING_DIR_NAME); + + Slog.i(logTag, "Applying time zone update"); + File unpackedContentDir = unpackBundle(content, workingDir); + try { + if (!checkBundleFilesExist(unpackedContentDir)) { + Slog.i(logTag, "Update not applied: Bundle is missing files"); + return false; + } + + if (verifySystemChecksums(unpackedContentDir)) { + FileUtils.makeDirectoryWorldAccessible(unpackedContentDir); + + if (currentTzDataDir.exists()) { + Slog.i(logTag, "Moving " + currentTzDataDir + " to " + oldTzDataDir); + FileUtils.rename(currentTzDataDir, oldTzDataDir); + } + Slog.i(logTag, "Moving " + unpackedContentDir + " to " + currentTzDataDir); + FileUtils.rename(unpackedContentDir, currentTzDataDir); + Slog.i(logTag, "Update applied: " + currentTzDataDir + " successfully created"); + return true; + } + Slog.i(logTag, "Update not applied: System checksum did not match"); + return false; + } finally { + deleteBestEffort(oldTzDataDir); + deleteBestEffort(unpackedContentDir); + } + } + + private void deleteBestEffort(File dir) { + if (dir.exists()) { + try { + FileUtils.deleteRecursive(dir); + } catch (IOException e) { + // Logged but otherwise ignored. + Slog.w(logTag, "Unable to delete " + dir, e); + } + } + } + + private File unpackBundle(byte[] content, File targetDir) throws IOException { + Slog.i(logTag, "Unpacking update content to: " + targetDir); + ConfigBundle bundle = new ConfigBundle(content); + bundle.extractTo(targetDir); + return targetDir; + } + + private boolean checkBundleFilesExist(File unpackedContentDir) throws IOException { + Slog.i(logTag, "Verifying bundle contents"); + return FileUtils.filesExist(unpackedContentDir, + ConfigBundle.TZ_DATA_VERSION_FILE_NAME, + ConfigBundle.CHECKSUMS_FILE_NAME, + ConfigBundle.ZONEINFO_FILE_NAME, + ConfigBundle.ICU_DATA_FILE_NAME); + } + + private boolean verifySystemChecksums(File unpackedContentDir) throws IOException { + Slog.i(logTag, "Verifying system file checksums"); + File checksumsFile = new File(unpackedContentDir, ConfigBundle.CHECKSUMS_FILE_NAME); + for (String line : FileUtils.readLines(checksumsFile)) { + int delimiterPos = line.indexOf(','); + if (delimiterPos <= 0 || delimiterPos == line.length() - 1) { + throw new IOException("Bad checksum entry: " + line); + } + long expectedChecksum; + try { + expectedChecksum = Long.parseLong(line.substring(0, delimiterPos)); + } catch (NumberFormatException e) { + throw new IOException("Invalid checksum value: " + line); + } + String filePath = line.substring(delimiterPos + 1); + File file = new File(filePath); + if (!file.exists()) { + Slog.i(logTag, "Failed checksum test for file: " + file + ": file not found"); + return false; + } + long actualChecksum = FileUtils.calculateChecksum(file); + if (actualChecksum != expectedChecksum) { + Slog.i(logTag, "Failed checksum test for file: " + file + + ": required=" + expectedChecksum + ", actual=" + actualChecksum); + return false; + } + } + return true; + } +} |