#!/bin/bash -p # Copyright (c) 2009 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: keystone_install.sh update_dmg_mount_point # # Called by the Keystone system to update the installed application with a new # version from a disk image. # # Environment variables: # GOOGLE_CHROME_UPDATER_DEBUG # When set to a non-empty value, additional information about this script's # actions will be logged to stderr. The same debugging information will # also be enabled when "Library/Google/Google Chrome Updater Debug" in the # root directory or in ${HOME} exists. # # Exit codes: # 0 Happiness # 1 Unknown failure # 2 Basic sanity check source failure (e.g. no app on disk image) # 3 Basic sanity check destination failure (e.g. ticket points to nothing) # 4 Update driven by user ticket when a system ticket is also present # 5 Could not prepare existing installed version to receive update # 6 Patch sanity check failure # 7 rsync failed (could not copy new versioned directory to Versions) # 8 rsync failed (could not update outer .app bundle) # 9 Could not get the version, update URL, or channel after update # 10 Updated application does not have the version number from the update # 11 ksadmin failure # 12 dirpatcher failed for versioned directory # 13 dirpatcher failed for outer .app bundle # # The following exit codes are not used by this script, but can be used to # convey special meaning to Keystone: # 66 (unused) success, request reboot # 77 (unused) try installation again later set -eu # http://b/2290916: Keystone runs the installation with a restrictive PATH # that only includes the directory containing ksadmin, /bin, and /usr/bin. It # does not include /sbin or /usr/sbin. This script uses lsof, which is in # /usr/sbin, and it's conceivable that it might want to use other tools in an # sbin directory. Adjust the path accordingly. export PATH="${PATH}:/sbin:/usr/sbin" # Environment sanitization. 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. unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT export -n SHELLOPTS set -o pipefail shopt -s nullglob ME="$(basename "${0}")" readonly ME declare GOOGLE_CHROME_UPDATER_DEBUG err() { local error="${1}" local id= if [[ -n "${GOOGLE_CHROME_UPDATER_DEBUG}" ]]; then id=": ${$} $(date "+%Y-%m-%d %H:%M:%S %z")" fi echo "${ME}${id}: ${error}" >& 2 } note() { local message="${1}" if [[ -n "${GOOGLE_CHROME_UPDATER_DEBUG}" ]]; then err "${message}" fi } declare g_temp_dir cleanup() { local status=${?} trap - EXIT trap '' HUP INT QUIT TERM if [[ ${status} -ge 128 ]]; then err "Caught signal $((${status} - 128))" fi if [[ -n "${g_temp_dir}" ]]; then rm -rf "${g_temp_dir}" fi exit ${status} } ensure_temp_dir() { if [[ -z "${g_temp_dir}" ]]; then # Choose a template that won't be a dot directory. Make it safe by # removing leading hyphens, too. local template="${ME}" if [[ "${template}" =~ ^[-.]+(.*)$ ]]; then template="${BASH_REMATCH[1]}" fi if [[ -z "${template}" ]]; then template="keystone_install" fi g_temp_dir="$(mktemp -d -t "${template}")" note "g_temp_dir = ${g_temp_dir}" fi } # Returns 0 (true) if |symlink| exists, is a symbolic link, and appears # writable on the basis of its POSIX permissions. This is used to determine # writability like test's -w primary, but -w resolves symbolic links and this # function does not. is_writable_symlink() { local symlink="${1}" local link_mode link_mode="$(stat -f %Sp "${symlink}" 2> /dev/null || true)" if [[ -z "${link_mode}" ]] || [[ "${link_mode:0:1}" != "l" ]]; then return 1 fi local link_user link_group link_user="$(stat -f %u "${symlink}" 2> /dev/null || true)" link_group="$(stat -f %g "${symlink}" 2> /dev/null || true)" if [[ -z "${link_user}" ]] || [[ -z "${link_group}" ]]; then return 1 fi # If the users match, check the owner-write bit. if [[ ${EUID} -eq "${link_user}" ]]; then if [[ "${link_mode:2:1}" = "w" ]]; then return 0 fi return 1 fi # If the file's group matches any of the groups that this process is a # member of, check the group-write bit. local group_match= local group for group in "${GROUPS[@]}"; do if [[ "${group}" -eq "${link_group}" ]]; then group_match="y" break fi done if [[ -n "${group_match}" ]]; then if [[ "${link_mode:5:1}" = "w" ]]; then return 0 fi return 1 fi # Check the other-write bit. if [[ "${link_mode:8:1}" = "w" ]]; then return 0 fi return 1 } # If |symlink| exists and is a symbolic link, but is not writable according to # is_writable_symlink, this function attempts to replace it with a new # writable symbolic link. If |symlink| does not exist, is not a symbolic # link, or is already writable, this function does nothing. This function # always returns 0 (true). ensure_writable_symlink() { local symlink="${1}" if [[ -L "${symlink}" ]] && ! is_writable_symlink "${symlink}"; then # If ${symlink} refers to a directory, doing this naively might result in # the new link being placed in that directory, instead of replacing the # existing link. ln -fhs is supposed to handle this case, but it does so # by unlinking (removing) the existing symbolic link before creating a new # one. That leaves a small window during which the symbolic link is not # present on disk at all. # # To avoid that possibility, a new symbolic link is created in a temporary # location and then swapped into place with mv. An extra temporary # directory is used to convince mv to replace the symbolic link: again, if # the existing link refers to a directory, "mv newlink oldlink" will # actually leave oldlink alone and place newlink into the directory. # "mv newlink dirname(oldlink)" works as expected, but in order to replace # oldlink, newlink must have the same basename, hence the temporary # directory. local target target="$(readlink "${symlink}" 2> /dev/null || true)" if [[ -z "${target}" ]]; then return 0 fi # Error handling strategy: if anything fails, such as the mktemp, ln, # chmod, or mv, ignore the failure and return 0 (success), leaving the # existing state with the non-writable symbolic link intact. Failures # in this function will be difficult to understand and diagnose, and a # non-writable symbolic link is not necessarily fatal. If something else # requires a writable symbolic link, allowing it to fail when a symbolic # link is not writable is easier to understand than bailing out of the # script on failure here. local symlink_dir temp_link_dir temp_link symlink_dir="$(dirname "${symlink}")" temp_link_dir="$(mktemp -d "${symlink_dir}/.symlink_temp.XXXXXX" || true)" if [[ -z "${temp_link_dir}" ]]; then return 0 fi temp_link="${temp_link_dir}/$(basename "${symlink}")" (ln -fhs "${target}" "${temp_link}" && \ chmod -h 755 "${temp_link}" && \ mv -f "${temp_link}" "${symlink_dir}/") || true rm -rf "${temp_link_dir}" fi return 0 } # ensure_writable_symlinks_recursive calls ensure_writable_symlink for every # symbolic link in |directory|, recursivley. # # In some very weird and rare cases, it is possible to wind up with a user # installation that contains symbolic links that the user does not have write # permission over. More on how that might happen later. # # If a weird and rare case like this is observed, rsync will exit with an # error when attempting to update the times on these symbolic links. rsync # may not be intelligent enough to try creating a new symbolic link in these # cases, but this script can be. # # The problem occurs when an administrative user first drag-installs the # application to /Applications, resulting in the program's user being set to # the user's own ID. If, subsequently, a .pkg package is installed over that, # the existing directory ownership will be preserved, but file ownership will # be changed to whateer is specified by the package, typically root. This # applies to symbolic links as well. On a subsequent update, rsync will be # able to copy the new files into place, because the user still has permission # to write to the directories. If the symbolic link targets are not changing, # though, rsync will not replace them, and they will remain owned by root. # The user will not have permission to update the time on the symbolic links, # resulting in an rsync error. ensure_writable_symlinks_recursive() { local directory="${1}" # This fix-up is not necessary when running as root, because root will # always be able to write everything needed. if [[ ${EUID} -eq 0 ]]; then return 0 fi # This step isn't critical. local set_e= if [[ "${-}" =~ e ]]; then set_e="y" set +e fi # Use find -print0 with read -d $'\0' to handle even the weirdest paths. local symlink while IFS= read -r -d $'\0' symlink; do ensure_writable_symlink "${symlink}" done < <(find "${directory}" -type l -print0) # Go back to how things were. if [[ -n "${set_e}" ]]; then set -e fi } # Prints the version of ksadmin, as reported by ksadmin --ksadmin-version, to # stdout. This function operates with "static" variables: it will only check # the ksadmin version once per script run. If ksadmin is old enough to not # support --ksadmin-version, or another error occurs, this function prints an # empty string. g_checked_ksadmin_version= g_ksadmin_version= ksadmin_version() { if [[ -z "${g_checked_ksadmin_version}" ]]; then g_checked_ksadmin_version="y" g_ksadmin_version="$(ksadmin --ksadmin-version || true)" fi echo "${g_ksadmin_version}" return 0 } # Compares the installed ksadmin version against a supplied version number, # |check_version|, and returns 0 (true) if the installed Keystone version is # greater than or equal to |check_version| according to a piece-wise # comparison. Returns 1 (false) if the installed Keystone version number # cannot be determined or if |check_version| is greater than the installed # Keystone version. |check_version| should be a string of the form # "major.minor.micro.build". Returns 1 (false) if either |check_version| or # the Keystone version do not match this format. readonly KSADMIN_VERSION_RE="^([0-9]+)\\.([0-9]+)\\.([0-9]+)\\.([0-9]+)\$" is_ksadmin_version_ge() { local check_version="${1}" if ! [[ "${check_version}" =~ ${KSADMIN_VERSION_RE} ]]; then return 1 fi local check_components=("${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}") local ksadmin_version ksadmin_version="$(ksadmin_version)" if ! [[ "${ksadmin_version}" =~ ${KSADMIN_VERSION_RE} ]]; then return 1 fi local ksadmin_components=("${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}") local i for i in "${!check_components[@]}"; do local check_component="${check_components[${i}]}" local ksadmin_component="${ksadmin_components[${i}]}" if [[ ${ksadmin_component} -lt ${check_component} ]]; then # ksadmin_version is less than check_version. return 1 fi if [[ ${ksadmin_component} -gt ${check_component} ]]; then # ksadmin_version is greater than check_version. return 0 fi done # The version numbers are equal. return 0 } # Returns 0 (true) if ksadmin supports --tag. ksadmin_supports_tag() { local ksadmin_version ksadmin_version="$(ksadmin_version)" if [[ -n "${ksadmin_version}" ]]; then # A ksadmin that recognizes --ksadmin-version and provides a version # number is new enough to recognize --tag. return 0 fi return 1 } # Returns 0 (true) if ksadmin supports --tag-path and --tag-key. ksadmin_supports_tagpath_tagkey() { # --tag-path and --tag-key were introduced in Keystone 1.0.7.1306. is_ksadmin_version_ge 1.0.7.1306 # The return value of is_ksadmin_version_ge is used as this function's # return value. } # Returns 0 (true) if ksadmin supports --brand-path and --brand-key. ksadmin_supports_brandpath_brandkey() { # --brand-path and --brand-key were introduced in Keystone 1.0.8.1620. is_ksadmin_version_ge 1.0.8.1620 # The return value of is_ksadmin_version_ge is used as this function's # return value. } usage() { echo "usage: ${ME} update_dmg_mount_point" >& 2 } main() { local update_dmg_mount_point="${1}" # Early steps are critical. Don't continue past any failure. set -e trap cleanup EXIT HUP INT QUIT TERM readonly PRODUCT_NAME="Google Chrome" readonly APP_DIR="${PRODUCT_NAME}.app" readonly FRAMEWORK_NAME="${PRODUCT_NAME} Framework" readonly FRAMEWORK_DIR="${FRAMEWORK_NAME}.framework" readonly PATCH_DIR=".patch" readonly CONTENTS_DIR="Contents" readonly APP_PLIST="${CONTENTS_DIR}/Info" readonly VERSIONS_DIR="${CONTENTS_DIR}/Versions" readonly UNROOTED_BRAND_PLIST="Library/Google/Google Chrome Brand" readonly UNROOTED_DEBUG_FILE="Library/Google/Google Chrome Updater Debug" readonly APP_VERSION_KEY="CFBundleShortVersionString" readonly KS_VERSION_KEY="KSVersion" readonly KS_PRODUCT_KEY="KSProductID" readonly KS_URL_KEY="KSUpdateURL" readonly KS_CHANNEL_KEY="KSChannelID" readonly KS_BRAND_KEY="KSBrandID" readonly QUARANTINE_ATTR="com.apple.quarantine" # Don't use rsync -a, because -a expands to -rlptgoD. -g and -o copy owners # and groups, respectively, from the source, and that is undesirable in this # case. -D copies devices and special files; copying devices only works # when running as root, so for consistency between privileged and # unprivileged operation, this option is omitted as well. # -I, --ignore-times don't skip files that match in size and mod-time # -l, --links copy symlinks as symlinks # -r, --recursive recurse into directories # -p, --perms preserve permissions # -t, --times preserve times readonly RSYNC_FLAGS="-Ilprt" # It's difficult to get GOOGLE_CHROME_UPDATER_DEBUG set in the environment # when this script is called from Keystone. If a "debug file" exists in # either the root directory or the home directory of the user who owns the # ticket, turn on verbosity. This may aid debugging. if [[ -e "/${UNROOTED_DEBUG_FILE}" ]] || [[ -e ~/"${UNROOTED_DEBUG_FILE}" ]]; then export GOOGLE_CHROME_UPDATER_DEBUG="y" fi note "update_dmg_mount_point = ${update_dmg_mount_point}" # The argument should be the disk image path. Make sure it exists and that # it's an absolute path. note "checking update" if [[ -z "${update_dmg_mount_point}" ]] || [[ "${update_dmg_mount_point:0:1}" != "/" ]] || ! [[ -d "${update_dmg_mount_point}" ]]; then err "update_dmg_mount_point must be an absolute path to a directory" usage exit 2 fi local patch_dir="${update_dmg_mount_point}/${PATCH_DIR}" if [[ "${patch_dir:0:1}" != "/" ]]; then note "patch_dir = ${patch_dir}" err "patch_dir must be an absolute path" exit 2 fi # Figure out if this is an ordinary installation disk image being used as a # full update, or a patch. A patch will have a .patch directory at the root # of the disk image containing information about the update, tools to apply # it, and the update contents. local is_patch= local dirpatcher= if [[ -d "${patch_dir}" ]]; then # patch_dir exists and is a directory - this is a patch update. is_patch="y" dirpatcher="${patch_dir}/dirpatcher.sh" if ! [[ -x "${dirpatcher}" ]]; then err "couldn't locate dirpatcher" exit 6 fi elif [[ -e "${patch_dir}" ]]; then # patch_dir exists, but is not a directory - what's that mean? note "patch_dir = ${patch_dir}" err "patch_dir must be a directory" exit 2 else # patch_dir does not exist - this is a full "installer." patch_dir= fi note "patch_dir = ${patch_dir}" note "is_patch = ${is_patch}" note "dirpatcher = ${dirpatcher}" # The update to install. # update_app is the path to the new version of the .app. It will only be # set at this point for a non-patch update. It is not yet set for a patch # update because no such directory exists yet; it will be set later when # dirpatcher creates it. local update_app= # update_version_app_old, patch_app_dir, and patch_versioned_dir will only # be set for patch updates. local update_version_app_old= local patch_app_dir= local patch_versioned_dir= local update_version_app update_version_ks product_id if [[ -z "${is_patch}" ]]; then update_app="${update_dmg_mount_point}/${APP_DIR}" note "update_app = ${update_app}" # Make sure that there's something to copy from, and that it's an absolute # path. if [[ "${update_app:0:1}" != "/" ]] || ! [[ -d "${update_app}" ]]; then err "update_app must be an absolute path to a directory" exit 2 fi # Get some information about the update. note "reading update values" local update_app_plist="${update_app}/${APP_PLIST}" note "update_app_plist = ${update_app_plist}" if ! update_version_app="$(defaults read "${update_app_plist}" \ "${APP_VERSION_KEY}")" || [[ -z "${update_version_app}" ]]; then err "couldn't determine update_version_app" exit 2 fi note "update_version_app = ${update_version_app}" local update_ks_plist="${update_app_plist}" note "update_ks_plist = ${update_ks_plist}" if ! update_version_ks="$(defaults read "${update_ks_plist}" \ "${KS_VERSION_KEY}")" || [[ -z "${update_version_ks}" ]]; then err "couldn't determine update_version_ks" exit 2 fi note "update_version_ks = ${update_version_ks}" if ! product_id="$(defaults read "${update_ks_plist}" \ "${KS_PRODUCT_KEY}")" || [[ -z "${product_id}" ]]; then err "couldn't determine product_id" exit 2 fi note "product_id = ${product_id}" else # [[ -n "${is_patch}" ]] # Get some information about the update. note "reading update values" if ! update_version_app_old=$(<"${patch_dir}/old_app_version") || [[ -z "${update_version_app_old}" ]]; then err "couldn't determine update_version_app_old" exit 2 fi note "update_version_app_old = ${update_version_app_old}" if ! update_version_app=$(<"${patch_dir}/new_app_version") || [[ -z "${update_version_app}" ]]; then err "couldn't determine update_version_app" exit 2 fi note "update_version_app = ${update_version_app}" if ! update_version_ks=$(<"${patch_dir}/new_ks_version") || [[ -z "${update_version_ks}" ]]; then err "couldn't determine update_version_ks" exit 2 fi note "update_version_ks = ${update_version_ks}" if ! product_id=$(<"${patch_dir}/ks_product") || [[ -z "${product_id}" ]]; then err "couldn't determine product_id" exit 2 fi note "product_id = ${product_id}" patch_app_dir="${patch_dir}/application.dirpatch" if ! [[ -d "${patch_app_dir}" ]]; then err "couldn't locate patch_app_dir" exit 6 fi note "patch_app_dir = ${patch_app_dir}" patch_versioned_dir=\ "${patch_dir}/version_${update_version_app_old}_${update_version_app}.dirpatch" if ! [[ -d "${patch_versioned_dir}" ]]; then err "couldn't locate patch_versioned_dir" exit 6 fi note "patch_versioned_dir = ${patch_versioned_dir}" fi # ksadmin is required. Keystone should have set a ${PATH} that includes it. # Check that here, so that more useful feedback can be offered in the # unlikely event that ksadmin is missing. note "checking Keystone" local ksadmin_path if ! ksadmin_path="$(type -p ksadmin)" || [[ -z "${ksadmin_path}" ]]; then err "couldn't locate ksadmin_path" exit 3 fi note "ksadmin_path = ${ksadmin_path}" # Call ksadmin_version once to prime the global state. This is needed # because subsequent calls to ksadmin_version that occur in $(...) # expansions will not affect the global state (although they can read from # the already-initialized global state) and thus will cause a new ksadmin # --ksadmin-version process to run for each check unless the globals have # been properly initialized beforehand. ksadmin_version >& /dev/null || true local ksadmin_version_string ksadmin_version_string="$(ksadmin_version 2> /dev/null || true)" note "ksadmin_version_string = ${ksadmin_version_string}" # Figure out where to install. local installed_app if ! installed_app="$(ksadmin -pP "${product_id}" | sed -Ene \ "s%^[[:space:]]+xc=\$%\\1%p")" || [[ -z "${installed_app}" ]]; then err "couldn't locate installed_app" exit 3 fi note "installed_app = ${installed_app}" if [[ "${installed_app:0:1}" != "/" ]] || ! [[ -d "${installed_app}" ]]; then err "installed_app must be an absolute path to a directory" exit 3 fi # If this script is running as root, it's being driven by a system ticket. # Otherwise, it's being driven by a user ticket. local system_ticket= if [[ ${EUID} -eq 0 ]]; then system_ticket="y" fi note "system_ticket = ${system_ticket}" # If this script is being driven by a user ticket, but a system ticket is # also present, there's a potential for the two to collide. Both ticket # types might be present if another user on the system promoted the ticket # to system: the other user could not have removed this user's user ticket. # Handle that case here by deleting the user ticket and exiting early with # a discrete exit code. # # Current versions of ksadmin will exit 1 (false) when asked to print tickets # and given a specific product ID to print. Older versions of ksadmin would # exit 0 (true), but those same versions did not support -S (meaning to check # the system ticket store) and would exit 1 (false) with this invocation due # to not understanding the question. Therefore, the usage here will only # delete the existing user ticket when running as non-root with access to a # sufficiently recent ksadmin. Older ksadmins are tolerated: the update will # likely fail for another reason and the user ticket will hang around until # something is eventually able to remove it. if [[ -z "${system_ticket}" ]] && ksadmin -S --print-tickets -P "${product_id}" >& /dev/null; then ksadmin --delete -P "${product_id}" || true err "can't update on a user ticket when a system ticket is also present" exit 4 fi # Figure out what the existing installed application is using for its # versioned directory. This will be used later, to avoid removing the # existing installed version's versioned directory in case anything is still # using it. note "reading install values" local installed_app_plist="${installed_app}/${APP_PLIST}" note "installed_app_plist = ${installed_app_plist}" local installed_app_plist_path="${installed_app_plist}.plist" note "installed_app_plist_path = ${installed_app_plist_path}" local old_version_app old_version_app="$(defaults read "${installed_app_plist}" \ "${APP_VERSION_KEY}" || true)" note "old_version_app = ${old_version_app}" # old_version_app is not required, because it won't be present in skeleton # bootstrap installations, which just have an empty .app directory. Only # require it when doing a patch update, and use it to validate that the # patch applies to the old installed version. By definition, skeleton # bootstraps can't be installed with patch udpates. They require the full # application on the disk image. if [[ -n "${is_patch}" ]]; then if [[ -z "${old_version_app}" ]]; then err "old_version_app required for patch" exit 6 elif [[ "${old_version_app}" != "${update_version_app_old}" ]]; then err "this patch does not apply to the installed version" exit 6 fi fi local installed_versions_dir="${installed_app}/${VERSIONS_DIR}" note "installed_versions_dir = ${installed_versions_dir}" # If the installed application is incredibly old, old_versioned_dir may not # exist. local old_versioned_dir if [[ -n "${old_version_app}" ]]; then old_versioned_dir="${installed_versions_dir}/${old_version_app}" fi note "old_versioned_dir = ${old_versioned_dir}" # Collect the installed application's brand code, it will be used later. It # is not an error for the installed application to not have a brand code. local old_ks_plist="${installed_app_plist}" note "old_ks_plist = ${old_ks_plist}" local old_brand old_brand="$(defaults read "${old_ks_plist}" \ "${KS_BRAND_KEY}" 2> /dev/null || true)" note "old_brand = ${old_brand}" ensure_writable_symlinks_recursive "${installed_app}" # By copying to ${installed_app}, the existing application name will be # preserved, if the user has renamed the application on disk. Respecting # the user's changes is friendly. # Make sure that ${installed_versions_dir} exists, so that it can receive # the versioned directory. It may not exist if updating from an older # version that did not use the versioned layout on disk. Later, during the # rsync to copy the applciation directory, the mode bits and timestamp on # ${installed_versions_dir} will be set to conform to whatever is present in # the update. # # ${installed_app} is guaranteed to exist at this point, but # ${installed_app}/${CONTENTS_DIR} may not if things are severely broken or # if this update is actually an initial installation from a Keystone # skeleton bootstrap. The mkdir creates ${installed_app}/${CONTENTS_DIR} if # it doesn't exist; its mode bits will be fixed up in a subsequent rsync. note "creating installed_versions_dir" if ! mkdir -p "${installed_versions_dir}"; then err "mkdir of installed_versions_dir failed" exit 5 fi local new_versioned_dir new_versioned_dir="${installed_versions_dir}/${update_version_app}" note "new_versioned_dir = ${new_versioned_dir}" # If there's an entry at ${new_versioned_dir} but it's not a directory # (or it's a symbolic link, whether or not it points to a directory), rsync # won't get rid of it. It's never correct to have a non-directory in place # of the versioned directory, so toss out whatever's there. Don't treat # this as a critical step: if removal fails, operation can still proceed to # to the dirpatcher or rsync, which will likely fail. if [[ -e "${new_versioned_dir}" ]] && ([[ -L "${new_versioned_dir}" ]] || ! [[ -d "${new_versioned_dir}" ]]); then note "removing non-directory in place of versioned directory" rm -f "${new_versioned_dir}" 2> /dev/null || true fi local update_versioned_dir if [[ -z "${is_patch}" ]]; then update_versioned_dir="${update_app}/${VERSIONS_DIR}/${update_version_app}" note "update_versioned_dir = ${update_versioned_dir}" else # [[ -n "${is_patch}" ]] # dirpatcher won't patch into a directory that already exists. Doing so # would be a bad idea, anyway. If ${new_versioned_dir} already exists, # it may be something left over from a previous failed or incomplete # update attempt, or it may be the live versioned directory if this is a # same-version update intended only to change channels. Since there's no # way to tell, this case is handled by having dirpatcher produce the new # versioned directory in a temporary location and then having rsync copy # it into place as an ${update_versioned_dir}, the same as in a non-patch # update. If ${new_versioned_dir} doesn't exist, dirpatcher can place the # new versioned directory at that location directly. local versioned_dir_target if ! [[ -e "${new_versioned_dir}" ]]; then versioned_dir_target="${new_versioned_dir}" note "versioned_dir_target = ${versioned_dir_target}" else ensure_temp_dir versioned_dir_target="${g_temp_dir}/${update_version_app}" note "versioned_dir_target = ${versioned_dir_target}" update_versioned_dir="${versioned_dir_target}" note "update_versioned_dir = ${update_versioned_dir}" fi note "dirpatching versioned directory" if ! "${dirpatcher}" "${old_versioned_dir}" \ "${patch_versioned_dir}" \ "${versioned_dir_target}"; then err "dirpatcher of versioned directory failed, status ${PIPESTATUS[0]}" exit 12 fi fi # Copy the versioned directory. The new versioned directory should have a # different name than any existing one, so this won't harm anything already # present in ${installed_versions_dir}, including the versioned directory # being used by any running processes. If this step is interrupted, there # will be an incomplete versioned directory left behind, but it won't # won't interfere with anything, and it will be replaced or removed during a # future update attempt. # # In certain cases, same-version updates are distributed to move users # between channels; when this happens, the contents of the versioned # directories are identical and rsync will not render the versioned # directory unusable even for an instant. # # ${update_versioned_dir} may be empty during a patch update (${is_patch}) # if the dirpatcher above was able to write it into place directly. In # that event, dirpatcher guarantees that ${new_versioned_dir} is already in # place. if [[ -n "${update_versioned_dir}" ]]; then note "rsyncing versioned directory" if ! rsync ${RSYNC_FLAGS} --delete-before "${update_versioned_dir}/" \ "${new_versioned_dir}"; then err "rsync of versioned directory failed, status ${PIPESTATUS[0]}" exit 7 fi fi if [[ -n "${is_patch}" ]]; then # If the versioned directory was prepared in a temporary directory and # then rsynced into place, remove the temporary copy now that it's no # longer needed. if [[ -n "${update_versioned_dir}" ]]; then rm -rf "${update_versioned_dir}" 2> /dev/null || true update_versioned_dir= note "update_versioned_dir = ${update_versioned_dir}" fi # Prepare ${update_app}. This always needs to be done in a temporary # location because dirpatcher won't write to a directory that already # exists, and ${installed_app} needs to be used as input to dirpatcher # in any event. The new application will be rsynced into place once # dirpatcher creates it. ensure_temp_dir update_app="${g_temp_dir}/${APP_DIR}" note "update_app = ${update_app}" note "dirpatching app directory" if ! "${dirpatcher}" "${installed_app}" \ "${patch_app_dir}" \ "${update_app}"; then err "dirpatcher of app directory failed, status ${PIPESTATUS[0]}" exit 13 fi fi # See if the timestamp of what's currently on disk is newer than the # update's outer .app's timestamp. rsync will copy the update's timestamp # over, but if that timestamp isn't as recent as what's already on disk, the # .app will need to be touched. local needs_touch= if [[ "${installed_app}" -nt "${update_app}" ]]; then needs_touch="y" fi note "needs_touch = ${needs_touch}" # Copy the unversioned files into place, leaving everything in # ${installed_versions_dir} alone. If this step is interrupted, the # application will at least remain in a usable state, although it may not # pass signature validation. Depending on when this step is interrupted, # the application will either launch the old or the new version. The # critical point is when the main executable is replaced. There isn't very # much to copy in this step, because most of the application is in the # versioned directory. This step only accounts for around 50 files, most of # which are small localized InfoPlist.strings files. Note that # ${VERSIONS_DIR} is included to copy its mode bits and timestamp, but its # contents are excluded, having already been installed above. note "rsyncing app directory" if ! rsync ${RSYNC_FLAGS} --delete-after --exclude "/${VERSIONS_DIR}/*" \ "${update_app}/" "${installed_app}"; then err "rsync of app directory failed, status ${PIPESTATUS[0]}" exit 8 fi note "rsyncs complete" if [[ -n "${is_patch}" ]]; then # update_app has been rsynced into place and is no longer needed. rm -rf "${update_app}" 2> /dev/null || true update_app= note "update_app = ${update_app}" fi if [[ -n "${g_temp_dir}" ]]; then # The temporary directory, if any, is no longer needed. rm -rf "${g_temp_dir}" 2> /dev/null || true g_temp_dir= note "g_temp_dir = ${g_temp_dir}" fi # If necessary, touch the outermost .app so that it appears to the outside # world that something was done to the bundle. This will cause # LaunchServices to invalidate the information it has cached about the # bundle even if lsregister does not run. This is not done if rsync already # updated the timestamp to something newer than what had been on disk. This # is not considered a critical step, and if it fails, this script will not # exit. if [[ -n "${needs_touch}" ]]; then touch -cf "${installed_app}" || true fi # Read the new values, such as the version. note "reading new values" local new_version_app if ! new_version_app="$(defaults read "${installed_app_plist}" \ "${APP_VERSION_KEY}")" || [[ -z "${new_version_app}" ]]; then err "couldn't determine new_version_app" exit 9 fi note "new_version_app = ${new_version_app}" local new_versioned_dir="${installed_versions_dir}/${new_version_app}" note "new_versioned_dir = ${new_versioned_dir}" local new_ks_plist="${installed_app_plist}" note "new_ks_plist = ${new_ks_plist}" local new_version_ks if ! new_version_ks="$(defaults read "${new_ks_plist}" \ "${KS_VERSION_KEY}")" || [[ -z "${new_version_ks}" ]]; then err "couldn't determine new_version_ks" exit 9 fi note "new_version_ks = ${new_version_ks}" local update_url if ! update_url="$(defaults read "${new_ks_plist}" "${KS_URL_KEY}")" || [[ -z "${update_url}" ]]; then err "couldn't determine update_url" exit 9 fi note "update_url = ${update_url}" # The channel ID is optional. Suppress stderr to prevent Keystone from # seeing possible error output. local channel channel="$(defaults read "${new_ks_plist}" "${KS_CHANNEL_KEY}" 2> /dev/null || true)" note "channel = ${channel}" # Make sure that the update was successful by comparing the version found in # the update with the version now on disk. if [[ "${new_version_ks}" != "${update_version_ks}" ]]; then err "new_version_ks and update_version_ks do not match" exit 10 fi # Notify LaunchServices. This is not considered a critical step, and # lsregister's exit codes shouldn't be confused with this script's own. # Redirect stdout to /dev/null to suppress the useless "ThrottleProcessIO: # throttling disk i/o" messages that lsregister might print. note "notifying LaunchServices" local cs_fwk="/System/Library/Frameworks/CoreServices.framework" local ls_fwk="${cs_fwk}/Frameworks/LaunchServices.framework" local lsregister="${ls_fwk}/Support/lsregister" note "cs_fwk = ${cs_fwk}" note "ls_fwk = ${ls_fwk}" note "lsregister = ${lsregister}" "${lsregister}" "${installed_app}" > /dev/null || true # The brand information is stored differently depending on whether this is # running for a system or user ticket. note "handling brand code" local set_brand_file_access= local brand_plist if [[ -n "${system_ticket}" ]]; then # System ticket. set_brand_file_access="y" brand_plist="/${UNROOTED_BRAND_PLIST}" else # User ticket. brand_plist=~/"${UNROOTED_BRAND_PLIST}" fi local brand_plist_path="${brand_plist}.plist" note "set_brand_file_access = ${set_brand_file_access}" note "brand_plist = ${brand_plist}" note "brand_plist_path = ${brand_plist_path}" # If the user manually updated their copy of Chrome, there might be new # brand information in the app bundle, and that needs to be copied out into # the file Keystone looks at. if [[ -n "${old_brand}" ]]; then local brand_dir brand_dir="$(dirname "${brand_plist_path}")" note "brand_dir = ${brand_dir}" if ! mkdir -p "${brand_dir}"; then err "couldn't mkdir brand_dir, continuing" else if ! defaults write "${brand_plist}" "${KS_BRAND_KEY}" \ -string "${old_brand}"; then err "couldn't write brand_plist, continuing" elif [[ -n "${set_brand_file_access}" ]]; then if ! chown "root:wheel" "${brand_plist_path}"; then err "couldn't chown brand_plist_path, continuing" else if ! chmod 644 "${brand_plist_path}"; then err "couldn't chmod brand_plist_path, continuing" fi fi fi fi fi # Confirm that the brand file exists. It's optional. local ksadmin_brand_plist_path="${brand_plist_path}" local ksadmin_brand_key="${KS_BRAND_KEY}" if [[ ! -f "${ksadmin_brand_plist_path}" ]]; then # Clear any branding information. ksadmin_brand_plist_path= ksadmin_brand_key= fi note "ksadmin_brand_plist_path = ${ksadmin_brand_plist_path}" note "ksadmin_brand_key = ${ksadmin_brand_key}" note "notifying Keystone" local ksadmin_args=( --register -P "${product_id}" --version "${new_version_ks}" --xcpath "${installed_app}" --url "${update_url}" ) if ksadmin_supports_tag; then ksadmin_args+=( --tag "${channel}" ) fi if ksadmin_supports_tagpath_tagkey; then ksadmin_args+=( --tag-path "${installed_app_plist_path}" --tag-key "${KS_CHANNEL_KEY}" ) fi if ksadmin_supports_brandpath_brandkey; then ksadmin_args+=( --brand-path "${ksadmin_brand_plist_path}" --brand-key "${ksadmin_brand_key}" ) fi note "ksadmin_args = ${ksadmin_args[*]}" if ! ksadmin "${ksadmin_args[@]}"; then err "ksadmin failed" exit 11 fi # The remaining steps are not considered critical. set +e # Try to clean up old versions that are not in use. The strategy is to keep # the versioned directory corresponding to the update just applied # (obviously) and the version that was just replaced, and to use ps and lsof # to see if it looks like any processes are currently using any other old # directories. Directories not in use are removed. Old versioned # directories that are in use are left alone so as to not interfere with # running processes. These directories can be cleaned up by this script on # future updates. # # To determine which directories are in use, both ps and lsof are used. # Each approach has limitations. # # The ps check looks for processes within the verisoned directory. Only # helper processes, such as renderers, are within the versioned directory. # Browser processes are not, so the ps check will not find them, and will # assume that a versioned directory is not in use if a browser is open # without any windows. The ps mechanism can also only detect processes # running on the system that is performing the update. If network shares # are involved, all bets are off. # # The lsof check looks to see what processes have the framework dylib open. # Browser processes will have their versioned framework dylib open, so this # check is able to catch browsers even if there are no associated helper # processes. Like the ps check, the lsof check is limited to processes on # the system that is performing the update. Finally, unless running as # root, the lsof check can only find processes running as the effective user # performing the update. # # These limitations are motiviations to additionally preserve the versioned # directory corresponding to the version that was just replaced. note "cleaning up old versioned directories" local versioned_dir for versioned_dir in "${installed_versions_dir}/"*; do note "versioned_dir = ${versioned_dir}" if [[ "${versioned_dir}" = "${new_versioned_dir}" ]] || \ [[ "${versioned_dir}" = "${old_versioned_dir}" ]]; then # This is the versioned directory corresponding to the update that was # just applied or the version that was previously in use. Leave it # alone. note "versioned_dir is new_versioned_dir or old_versioned_dir, skipping" continue fi # Look for any processes whose executables are within this versioned # directory. They'll be helper processes, such as renderers. Their # existence indicates that this versioned directory is currently in use. local ps_string="${versioned_dir}/" note "ps_string = ${ps_string}" # Look for any processes using the framework dylib. This will catch # browser processes where the ps check will not, but it is limited to # processes running as the effective user. local lsof_file="${versioned_dir}/${FRAMEWORK_DIR}/${FRAMEWORK_NAME}" note "lsof_file = ${lsof_file}" # ps -e displays all users' processes, -ww causes ps to not truncate # lines, -o comm instructs it to only print the command name, and the = # tells it to not print a header line. # The cut invocation filters the ps output to only have at most the number # of characters in ${ps_string}. This is done so that grep can look for # an exact match. # grep -F tells grep to look for lines that are exact matches (not regular # expressions), -q tells it to not print any output and just indicate # matches by exit status, and -x tells it that the entire line must match # ${ps_string} exactly, as opposed to matching a substring. A match # causes grep to exit zero (true). # # lsof will exit nonzero if ${lsof_file} does not exist or is open by any # process. If the file exists and is open, it will exit zero (true). if (! ps -ewwo comm= | \ cut -c "1-${#ps_string}" | \ grep -Fqx "${ps_string}") && (! lsof "${lsof_file}" >& /dev/null); then # It doesn't look like anything is using this versioned directory. Get # rid of it. note "versioned_dir doesn't appear to be in use, removing" rm -rf "${versioned_dir}" else note "versioned_dir is in use, skipping" fi done # If this script is being driven by a user Keystone ticket, it is not # running as root. If the application is installed somewhere under # /Applications, try to make it writable by all admin users. This will # allow other admin users to update the application from their own user # Keystone instances. # # If the script is being driven by a user Keystone ticket (not running as # root) and the application is not installed under /Applications, it might # not be in a system-wide location, and it probably won't be something that # other users on the system are running, so err on the side of safety and # don't make it group-writable. # # If this script is being driven by a system ticket (running as root), it's # future updates can be expected to be applied the same way, so admin- # writability is not a concern. Set the entire thing to be owned by root # in that case, regardless of where it's installed, and drop any group and # other write permission. # # If this script is running as a user that is not a member of the admin # group, the chgrp operation will not succeed. Tolerate that case, because # it's better than the alternative, which is to make the application # world-writable. note "setting permissions" local chmod_mode="a+rX,u+w,go-w" if [[ -z "${system_ticket}" ]]; then if [[ "${installed_app:0:14}" = "/Applications/" ]] && chgrp -Rh admin "${installed_app}" 2> /dev/null; then chmod_mode="a+rX,ug+w,o-w" fi else chown -Rh root:wheel "${installed_app}" 2> /dev/null fi note "chmod_mode = ${chmod_mode}" chmod -R "${chmod_mode}" "${installed_app}" 2> /dev/null # On the Mac, or at least on HFS+, symbolic link permissions are significant, # but chmod -R and -h can't be used together. Do another pass to fix the # permissions on any symbolic links. find "${installed_app}" -type l -exec chmod -h "${chmod_mode}" {} + \ 2> /dev/null # Host OS version check, to be able to take advantage of features on newer # systems and fall back to slow ways of doing things on older systems. local os_version os_version="$(sw_vers -productVersion)" note "os_version = ${os_version}" local os_major=0 local os_minor=0 if [[ "${os_version}" =~ ^([0-9]+)\.([0-9]+) ]]; then os_major="${BASH_REMATCH[1]}" os_minor="${BASH_REMATCH[2]}" fi note "os_major = ${os_major}" note "os_minor = ${os_minor}" # If an update is triggered from within the application itself, the update # process inherits the quarantine bit (LSFileQuarantineEnabled). Any files # or directories created during the update will be quarantined in that case, # which may cause Launch Services to display quarantine UI. That's bad, # especially if it happens when the outer .app launches a quarantined inner # helper. If the application is already on the system and is being updated, # then it can be assumed that it should not be quarantined. Use xattr to # drop the quarantine attribute. # # TODO(mark): Instead of letting the quarantine attribute be set and then # dropping it here, figure out a way to get the update process to run # without LSFileQuarantineEnabled even when triggering an update from within # the application. note "lifting quarantine" if [[ ${os_major} -gt 10 ]] || ([[ ${os_major} -eq 10 ]] && [[ ${os_minor} -ge 6 ]]); then # On 10.6, xattr supports -r for recursive operation. xattr -d -r "${QUARANTINE_ATTR}" "${installed_app}" 2> /dev/null else # On earlier systems, xattr doesn't support -r, so run xattr via find. find "${installed_app}" -exec xattr -d "${QUARANTINE_ATTR}" {} + \ 2> /dev/null fi # Great success! note "done!" trap - EXIT return 0 } # Check "less than" instead of "not equal to" in case Keystone ever changes to # pass more arguments. if [[ ${#} -lt 1 ]]; then usage exit 2 fi main "${@}" exit ${?}