#!/bin/bash -p # Copyright (c) 2011 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. # usage: dirdiffer.sh old_dir new_dir patch_dir # # dirdiffer creates a patch directory patch_dir that represents the difference # between old_dir and new_dir. patch_dir can be used with dirpatcher to # recreate new_dir given old_dir. # # dirdiffer operates recursively, properly handling ordinary files, symbolic # links, and directories, as they are found in new_dir. Symbolic links and # directories are always replicated as-is in patch_dir. Ordinary files will # be represented at the appropriate location in patch_dir by one of the # following: # # - a binary diff prepared by goobsdiff that can transform the file at the # same position in old_dir to the version in new_dir, but only when such a # file already exists in old_dir and is an ordinary file. These files are # given a "$gbs" suffix. # - a bzip2-compressed copy of the new file from new_dir; in patch_dir, the # new file will have a "$bz2" suffix. # - a gzip-compressed copy of the new file from new_dir; in patch_dir, the # new file will have a "$gz" suffix. # - an xz/lzma2-compressed copy of the new file from new_dir; in patch_dir, # the new file will have an "$xz" suffix. # - an uncompressed copy of the new file from new_dir; in patch_dir, the # new file will have a "$raw" suffix. # # The unconventional suffixes are used because they aren't likely to occur in # filenames. # # Of these options, the smallest possible representation is chosen. Note that # goobsdiff itself will also compress various sections of a binary diff with # bzip2, gzip, or xz/lzma2, or leave them uncompressed, according to which is # smallest. The approach of choosing the smallest possible representation is # time-consuming but given the choices of compressors results in an overall # size reduction of about 3%-5% relative to using bzip2 as the only # compressor; bzip2 is generally more effective for these data sets than gzip, # and xz/lzma2 more effective than bzip2. # # For large input files, goobsdiff is also very time-consuming and # memory-intensive. The overall "wall clock time" spent preparing a patch_dir # representing the differences between Google Chrome's 6.0.422.0 and 6.0.427.0 # versioned directories from successive weekly dev channel releases on a # 2.53GHz dual-core 4GB MacBook Pro is 3 minutes. Reconstructing new_dir with # dirpatcher is much quicker; in the above configuration, only 10 seconds are # needed for reconstruction. # # After creating a full patch_dir structure, but before returning, dirpatcher # is invoked to attempt to recreate new_dir in a temporary location given # old_dir and patch_dir. The recreated new_dir is then compared against the # original new_dir as a verification step. Should verification fail, dirdiffer # exits with a nonzero status, and patch_dir should not be used. # # Environment variables: # DIRDIFFER_EXCLUDE # When an entry in new_dir matches this regular expression, it will not be # included in patch_dir. All prospective paths in new_dir will be matched # against this regular expression, including directories. If a directory # matches this pattern, dirdiffer will also ignore the directory's contents. # DIRDIFFER_NO_DIFF # When an entry in new_dir matches this regular expression, it will not be # represented in patch_dir by a $gbs file prepared by goobsdiff. It will only # appear as a $bz2, $gz, or $raw file. Only files in new_dir, not # directories, will be matched against this regular expression. # # Exit codes: # 0 OK # 1 Unknown failure # 2 Incorrect number of parameters # 3 Input directories do not exist or are not directories # 4 Output directory already exists # 5 Parent of output directory does not exist or is not a directory # 6 An input or output directories contains another # 7 Could not create output directory # 8 File already exists in output directory # 9 Found an irregular file (non-directory, file, or symbolic link) in input # 10 Could not create symbolic link # 11 File copy failed # 12 bzip2 compression failed # 13 gzip compression failed # 14 xz/lzma2 compression failed # 15 Patch creation failed # 16 Verification failed # 17 Could not set mode (permissions) # 18 Could not set modification time # 19 Invalid regular expression (irregular expression?) set -eu # Environment sanitization. Set a known-safe PATH. Clear environment variables # that might impact the interpreter's operation. The |bash -p| invocation # on the #! line takes the bite out of BASH_ENV, ENV, and SHELLOPTS (among # other features), but clearing them here ensures that they won't impact any # shell scripts used as utility programs. SHELLOPTS is read-only and can't be # unset, only unexported. export PATH="/usr/bin:/bin:/usr/sbin:/sbin" unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT export -n SHELLOPTS shopt -s dotglob nullglob # find_tool looks for an executable file named |tool_name|: # - in the same directory as this script, # - if this script is located in a Chromium source tree, at the expected # Release output location in the Mac out directory, # - as above, but in the Debug output location # If found in any of the above locations, the script's path is output. # Otherwise, this function outputs |tool_name| as a fallback, allowing it to # be found (or not) by an ordinary ${PATH} search. find_tool() { local tool_name="${1}" local script_dir script_dir="$(dirname "${0}")" local tool="${script_dir}/${tool_name}" if [[ -f "${tool}" ]] && [[ -x "${tool}" ]]; then echo "${tool}" return fi local script_dir_phys script_dir_phys="$(cd "${script_dir}" && pwd -P)" if [[ "${script_dir_phys}" =~ ^(.*)/src/chrome/installer/mac$ ]]; then tool="${BASH_REMATCH[1]}/src/out/Release/${tool_name}" if [[ -f "${tool}" ]] && [[ -x "${tool}" ]]; then echo "${tool}" return fi tool="${BASH_REMATCH[1]}/src/out/Debug/${tool_name}" if [[ -f "${tool}" ]] && [[ -x "${tool}" ]]; then echo "${tool}" return fi fi echo "${tool_name}" } ME="$(basename "${0}")" readonly ME DIRPATCHER="$(dirname "${0}")/dirpatcher.sh" readonly DIRPATCHER GOOBSDIFF="$(find_tool goobsdiff)" readonly GOOBSDIFF readonly BZIP2="bzip2" readonly GZIP="gzip" XZ="$(find_tool xz)" readonly XZ readonly GBS_SUFFIX='$gbs' readonly BZ2_SUFFIX='$bz2' readonly GZ_SUFFIX='$gz' readonly XZ_SUFFIX='$xz' readonly PLAIN_SUFFIX='$raw' # Workaround for http://code.google.com/p/chromium/issues/detail?id=83180#c3 # In bash 4.0, "declare VAR" no longer initializes VAR if not already set. : ${DIRDIFFER_EXCLUDE:=} : ${DIRDIFFER_NO_DIFF:=} err() { local error="${1}" echo "${ME}: ${error}" >& 2 } declare -a g_cleanup g_verify_exclude cleanup() { local status=${?} trap - EXIT trap '' HUP INT QUIT TERM if [[ ${status} -ge 128 ]]; then err "Caught signal $((${status} - 128))" fi if [[ "${#g_cleanup[@]}" -gt 0 ]]; then rm -rf "${g_cleanup[@]}" fi exit ${status} } copy_mode_and_time() { local new_file="${1}" local patch_file="${2}" local mode mode="$(stat "-f%OMp%OLp" "${new_file}")" if ! chmod -h "${mode}" "${patch_file}"; then exit 17 fi if ! [[ -L "${patch_file}" ]]; then # Symbolic link modification times can't be copied because there's no # shell tool that provides direct access to lutimes. Instead, the symbolic # link was created with rsync, which already copied the timestamp with # lutimes. if ! touch -r "${new_file}" "${patch_file}"; then exit 18 fi fi } file_size() { local file="${1}" stat -f %z "${file}" } make_patch_file() { local old_file="${1}" local new_file="${2}" local patch_file="${3}" local uncompressed_file="${patch_file}${PLAIN_SUFFIX}" if ! cp "${new_file}" "${uncompressed_file}"; then exit 11 fi local uncompressed_size uncompressed_size="$(file_size "${new_file}")" local keep_file="${uncompressed_file}" local keep_size="${uncompressed_size}" local bz2_file="${patch_file}${BZ2_SUFFIX}" if [[ -e "${bz2_file}" ]]; then err "${bz2_file} already exists" exit 8 fi if ! "${BZIP2}" -9c < "${new_file}" > "${bz2_file}"; then err "couldn't compress ${new_file} to ${bz2_file} with ${BZIP2}" exit 12 fi local bz2_size bz2_size="$(file_size "${bz2_file}")" if [[ "${bz2_size}" -ge "${keep_size}" ]]; then rm -f "${bz2_file}" else rm -f "${keep_file}" keep_file="${bz2_file}" keep_size="${bz2_size}" fi local gz_file="${patch_file}${GZ_SUFFIX}" if [[ -e "${gz_file}" ]]; then err "${gz_file} already exists" exit 8 fi if ! "${GZIP}" -9cn < "${new_file}" > "${gz_file}"; then err "couldn't compress ${new_file} to ${gz_file} with ${GZIP}" exit 13 fi local gz_size gz_size="$(file_size "${gz_file}")" if [[ "${gz_size}" -ge "${keep_size}" ]]; then rm -f "${gz_file}" else rm -f "${keep_file}" keep_file="${gz_file}" keep_size="${gz_size}" fi local xz_flags=("-c") # If the file looks like a Mach-O file, including a universal/fat file, add # the x86 BCJ filter, which results in slightly better compression of x86 # and x86_64 executables. Mach-O files might contain other architectures, # but they aren't currently expected in Chrome. local file_output file_output="$(file "${new_file}" 2> /dev/null || true)" if [[ "${file_output}" =~ Mach-O ]]; then xz_flags+=("--x86") fi # Use an lzma2 encoder. This is equivalent to xz -9 -e, but allows filters # to precede the compressor. xz_flags+=("--lzma2=preset=9e") local xz_file="${patch_file}${XZ_SUFFIX}" if [[ -e "${xz_file}" ]]; then err "${xz_file} already exists" exit 8 fi if ! "${XZ}" "${xz_flags[@]}" < "${new_file}" > "${xz_file}"; then err "couldn't compress ${new_file} to ${xz_file} with ${XZ}" exit 14 fi local xz_size xz_size="$(file_size "${xz_file}")" if [[ "${xz_size}" -ge "${keep_size}" ]]; then rm -f "${xz_file}" else rm -f "${keep_file}" keep_file="${xz_file}" keep_size="${xz_size}" fi if [[ -f "${old_file}" ]] && ! [[ -L "${old_file}" ]] && ! [[ "${new_file}" =~ ${DIRDIFFER_NO_DIFF} ]]; then local gbs_file="${patch_file}${GBS_SUFFIX}" if [[ -e "${gbs_file}" ]]; then err "${gbs_file} already exists" exit 8 fi if ! "${GOOBSDIFF}" "${old_file}" "${new_file}" "${gbs_file}"; then err "couldn't create ${gbs_file} by comparing ${old_file} to ${new_file}" exit 15 fi local gbs_size gbs_size="$(file_size "${gbs_file}")" if [[ "${gbs_size}" -ge "${keep_size}" ]]; then rm -f "${gbs_file}" else rm -f "${keep_file}" keep_file="${gbs_file}" keep_size="${gbs_size}" fi fi copy_mode_and_time "${new_file}" "${keep_file}" } make_patch_symlink() { local new_file="${1}" local patch_file="${2}" # local target # target="$(readlink "${new_file}")" # ln -s "${target}" "${patch_file}" # Use rsync instead of the above, as it's the only way to preserve the # timestamp of a symbolic link using shell tools. if ! rsync -lt "${new_file}" "${patch_file}"; then exit 10 fi copy_mode_and_time "${new_file}" "${patch_file}" } make_patch_dir() { local old_dir="${1}" local new_dir="${2}" local patch_dir="${3}" if ! mkdir "${patch_dir}"; then exit 7 fi local new_file for new_file in "${new_dir}/"*; do local file="${new_file:${#new_dir} + 1}" local old_file="${old_dir}/${file}" local patch_file="${patch_dir}/${file}" if [[ "${new_file}" =~ ${DIRDIFFER_EXCLUDE} ]]; then g_verify_exclude+=("${new_file}") continue fi if [[ -e "${patch_file}" ]]; then err "${patch_file} already exists" exit 8 fi if [[ -L "${new_file}" ]]; then make_patch_symlink "${new_file}" "${patch_file}" elif [[ -d "${new_file}" ]]; then make_patch_dir "${old_file}" "${new_file}" "${patch_file}" elif [[ ! -f "${new_file}" ]]; then err "can't handle irregular file ${new_file}" exit 9 else make_patch_file "${old_file}" "${new_file}" "${patch_file}" fi done copy_mode_and_time "${new_dir}" "${patch_dir}" } verify_patch_dir() { local old_dir="${1}" local new_dir="${2}" local patch_dir="${3}" local verify_temp_dir verify_dir verify_temp_dir="$(mktemp -d -t "${ME}")" g_cleanup+=("${verify_temp_dir}") verify_dir="${verify_temp_dir}/patched" if ! "${DIRPATCHER}" "${old_dir}" "${patch_dir}" "${verify_dir}"; then err "patch application for verification failed" exit 16 fi # rsync will print a line for any file, directory, or symbolic link that # differs or exists only in one directory. As used here, it correctly # considers link targets, file contents, permissions, and timestamps. local rsync_command=(rsync -clprt --delete --out-format=%n \ "${new_dir}/" "${verify_dir}") if [[ ${#g_verify_exclude[@]} -gt 0 ]]; then local exclude for exclude in "${g_verify_exclude[@]}"; do # ${g_verify_exclude[@]} contains paths in ${new_dir}. Strip off # ${new_dir} from the beginning of each, but leave a leading "/" so that # rsync treats them as being at the root of the "transfer." rsync_command+=("--exclude" "${exclude:${#new_dir}}") done fi local rsync_output if ! rsync_output="$("${rsync_command[@]}")"; then err "rsync for verification failed" exit 16 fi rm -rf "${verify_temp_dir}" unset g_cleanup[${#g_cleanup[@]}] if [[ -n "${rsync_output}" ]]; then err "verification failed" exit 16 fi } # shell_safe_path ensures that |path| is safe to pass to tools as a # command-line argument. If the first character in |path| is "-", "./" is # prepended to it. The possibly-modified |path| is output. shell_safe_path() { local path="${1}" if [[ "${path:0:1}" = "-" ]]; then echo "./${path}" else echo "${path}" fi } dirs_contained() { local dir1="${1}/" local dir2="${2}/" if [[ "${dir1:0:${#dir2}}" = "${dir2}" ]] || [[ "${dir2:0:${#dir1}}" = "${dir1}" ]]; then return 0 fi return 1 } usage() { echo "usage: ${ME} old_dir new_dir patch_dir" >& 2 } main() { local old_dir new_dir patch_dir old_dir="$(shell_safe_path "${1}")" new_dir="$(shell_safe_path "${2}")" patch_dir="$(shell_safe_path "${3}")" trap cleanup EXIT HUP INT QUIT TERM if ! [[ -d "${old_dir}" ]] || ! [[ -d "${new_dir}" ]]; then err "old_dir and new_dir must exist and be directories" usage exit 3 fi if [[ -e "${patch_dir}" ]]; then err "patch_dir must not exist" usage exit 4 fi local patch_dir_parent patch_dir_parent="$(dirname "${patch_dir}")" if ! [[ -d "${patch_dir_parent}" ]]; then err "patch_dir parent directory must exist and be a directory" usage exit 5 fi # The weird conditional structure is because the status of the RE comparison # needs to be available in ${?} without conflating it with other conditions # or negating it. Only a status of 2 from the =~ operator indicates an # invalid regular expression. if [[ -n "${DIRDIFFER_EXCLUDE}" ]]; then if [[ "" =~ ${DIRDIFFER_EXCLUDE} ]]; then true elif [[ ${?} -eq 2 ]]; then err "DIRDIFFER_EXCLUDE contains an invalid regular expression" exit 19 fi fi if [[ -n "${DIRDIFFER_NO_DIFF}" ]]; then if [[ "" =~ ${DIRDIFFER_NO_DIFF} ]]; then true elif [[ ${?} -eq 2 ]]; then err "DIRDIFFER_NO_DIFF contains an invalid regular expression" exit 19 fi fi local old_dir_phys new_dir_phys patch_dir_parent_phys patch_dir_phys old_dir_phys="$(cd "${old_dir}" && pwd -P)" new_dir_phys="$(cd "${new_dir}" && pwd -P)" patch_dir_parent_phys="$(cd "${patch_dir_parent}" && pwd -P)" patch_dir_phys="${patch_dir_parent_phys}/$(basename "${patch_dir}")" if dirs_contained "${old_dir_phys}" "${new_dir_phys}" || dirs_contained "${old_dir_phys}" "${patch_dir_phys}" || dirs_contained "${new_dir_phys}" "${patch_dir_phys}"; then err "directories must not contain one another" usage exit 6 fi g_cleanup[${#g_cleanup[@]}]="${patch_dir}" make_patch_dir "${old_dir}" "${new_dir}" "${patch_dir}" verify_patch_dir "${old_dir}" "${new_dir}" "${patch_dir}" unset g_cleanup[${#g_cleanup[@]}] trap - EXIT } if [[ ${#} -ne 3 ]]; then usage exit 2 fi main "${@}" exit ${?}