1 #!/bin/bash -p 2 3 # Copyright (c) 2012 The Chromium Authors. All rights reserved. 4 # Use of this source code is governed by a BSD-style license that can be 5 # found in the LICENSE file. 6 7 # usage: dmgdiffer.sh product_name old_dmg new_dmg patch_dmg 8 # 9 # dmgdiffer creates a disk image containing a binary update able to patch 10 # a product originally distributed in old_dmg to the version in new_dmg. Much 11 # of this script is generic, but the make_patch_fs function is specific to 12 # a product: in this case, Google Chrome. 13 # 14 # This script operates by mounting old_dmg and new_dmg, creating a new 15 # filesystem structure containing dirpatches generated by dirdiffer and 16 # goobsdiff (which should be located in the same directory as this script), 17 # and producing a disk image from that structure. 18 # 19 # The Chrome make_patch_fs function produces an disk image that is able to 20 # update a single old version on any Keystone channel to a new version on a 21 # specific Keystone channel (the Keystone channel associated with new_dmg). 22 # Chrome's updates are split into two dirpatches: one updates the old 23 # versioned directory to the new one, and the other updates the remainder of 24 # the application. The versioned directory is split out from the rest because 25 # it contains the bulk of the application and its name changes from version to 26 # version, and dirdiffer/dirpatcher do not directly handle name changes. This 27 # approach also allows the versioned directory dirpatch to be applied in-place 28 # in most cases during an update, rather than relying on a temporary 29 # directory. In order to allow a single update dmg to apply to an old version 30 # on any Keystone channel, several small files are never distributed as diffs, 31 # and only as full (possibly compressed) versions of the new files. These 32 # files include the outer application's Info.plist which contains Keystone 33 # channel information, and anything created or modified by code-signing the 34 # outer application. 35 # 36 # Application of update disk images produced by this script is 37 # product-specific. With updates managed by Keystone, the update disk images 38 # can contain a .keystone_install script that is able to locate and update 39 # the installed product. 40 # 41 # Exit codes: 42 # 0 OK 43 # 1 Unknown failure 44 # 2 Incorrect number of parameters 45 # 3 Input disk images do not exist 46 # 4 Output disk image already exists 47 # 5 Parent of output directory does not exist or is not a directory 48 # 6 Could not mount old_dmg 49 # 7 Could not mount new_dmg 50 # 8 Could not create temporary patch filesystem directory 51 # 9 Could not create disk image 52 # 10 Could not read old application data 53 # 11 Could not read new application data 54 # 12 Old or new application sanity check failure 55 # 13 Could not write the patch 56 # 57 # Exit codes in the range 21-40 are mapped to codes 1-20 as returned by the 58 # first dirdiffer invocation. Codes 41-60 are mapped to codes 1-20 as returned 59 # by the second. 60 61 set -eu 62 63 # Environment sanitization. Set a known-safe PATH. Clear environment variables 64 # that might impact the interpreter's operation. The |bash -p| invocation 65 # on the #! line takes the bite out of BASH_ENV, ENV, and SHELLOPTS (among 66 # other features), but clearing them here ensures that they won't impact any 67 # shell scripts used as utility programs. SHELLOPTS is read-only and can't be 68 # unset, only unexported. 69 export PATH="/usr/bin:/bin:/usr/sbin:/sbin" 70 unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT 71 export -n SHELLOPTS 72 73 ME="$(basename "${0}")" 74 readonly ME 75 SCRIPT_DIR="$(dirname "${0}")" 76 readonly SCRIPT_DIR 77 readonly DIRDIFFER="${SCRIPT_DIR}/dirdiffer.sh" 78 readonly PKG_DMG="${SCRIPT_DIR}/pkg-dmg" 79 80 err() { 81 local error="${1}" 82 83 echo "${ME}: ${error}" >& 2 84 } 85 86 declare -a g_cleanup g_cleanup_mount_points 87 cleanup() { 88 local status=${?} 89 90 trap - EXIT 91 trap '' HUP INT QUIT TERM 92 93 if [[ ${status} -ge 128 ]]; then 94 err "Caught signal $((${status} - 128))" 95 fi 96 97 if [[ "${#g_cleanup_mount_points[@]}" -gt 0 ]]; then 98 local mount_point 99 for mount_point in "${g_cleanup_mount_points[@]}"; do 100 hdiutil detach "${mount_point}" -force >& /dev/null || true 101 done 102 fi 103 104 if [[ "${#g_cleanup[@]}" -gt 0 ]]; then 105 rm -rf "${g_cleanup[@]}" 106 fi 107 108 exit ${status} 109 } 110 111 mount_dmg() { 112 local dmg="${1}" 113 local mount_point="${2}" 114 115 if ! hdiutil attach "${1}" -mountpoint "${2}" \ 116 -nobrowse -owners off > /dev/null; then 117 # set -e is in effect. return ${?} so that the caller can check the return 118 # code if desired, perhaps to print a more useful error message or to exit 119 # with a more precise status than would be possible here. 120 return ${?} 121 fi 122 } 123 124 # make_patch_fs is responsible for comparing the old and new disk images 125 # mounted at old_fs and new_fs, respectively, and populating patch_fs with the 126 # contents of what will become a disk image able to update old_fs to new_fs. 127 # It then outputs a string which will be used as the volume name of the 128 # patch_dmg. 129 # 130 # The entire patch contents are placed into a .patch directory to hide them 131 # from ordinary view. The disk image will be given a volume name like 132 # "Google Chrome 5.0.375.55-5.0.375.70" as an identifying aid, although 133 # uniqueness is not important and users will never interact directly with 134 # them. 135 make_patch_fs() { 136 local product_name="${1}" 137 local old_fs="${2}" 138 local new_fs="${3}" 139 local patch_fs="${4}" 140 141 readonly APP_NAME="${product_name}.app" 142 readonly APP_NAME_RE="${product_name}\\.app" 143 readonly APP_PLIST="Contents/Info" 144 readonly APP_VERSION_KEY="CFBundleShortVersionString" 145 readonly APP_BUNDLEID_KEY="CFBundleIdentifier" 146 readonly KS_VERSION_KEY="KSVersion" 147 readonly KS_PRODUCT_KEY="KSProductID" 148 readonly KS_CHANNEL_KEY="KSChannelID" 149 readonly VERSIONS_DIR="Contents/Versions" 150 readonly BUILD_RE="^[0-9]+\\.[0-9]+\\.([0-9]+)\\.[0-9]+\$" 151 readonly MIN_BUILD=434 152 153 local product_url="http://www.google.com/chrome/" 154 if [[ "${product_name}" = "Google Chrome Canary" ]]; then 155 product_url="http://tools.google.com/dlpage/chromesxs" 156 fi 157 158 local old_app_path="${old_fs}/${APP_NAME}" 159 local old_app_plist="${old_app_path}/${APP_PLIST}" 160 local old_app_version 161 if ! old_app_version="$(defaults read "${old_app_plist}" \ 162 "${APP_VERSION_KEY}")"; then 163 err "could not read old app version" 164 exit 10 165 fi 166 if ! [[ "${old_app_version}" =~ ${BUILD_RE} ]]; then 167 err "old app version not of expected format" 168 exit 10 169 fi 170 local old_app_version_build="${BASH_REMATCH[1]}" 171 172 local old_app_bundleid 173 if ! old_app_bundleid="$(defaults read "${old_app_plist}" \ 174 "${APP_BUNDLEID_KEY}")"; then 175 err "could not read old app bundle ID" 176 exit 10 177 fi 178 179 local old_ks_plist="${old_app_plist}" 180 local old_ks_version 181 if ! old_ks_version="$(defaults read "${old_ks_plist}" \ 182 "${KS_VERSION_KEY}")"; then 183 err "could not read old Keystone version" 184 exit 10 185 fi 186 187 local new_app_path="${new_fs}/${APP_NAME}" 188 local new_app_plist="${new_app_path}/${APP_PLIST}" 189 local new_app_version 190 if ! new_app_version="$(defaults read "${new_app_plist}" \ 191 "${APP_VERSION_KEY}")"; then 192 err "could not read new app version" 193 exit 11 194 fi 195 if ! [[ "${new_app_version}" =~ ${BUILD_RE} ]]; then 196 err "new app version not of expected format" 197 exit 11 198 fi 199 local new_app_version_build="${BASH_REMATCH[1]}" 200 201 local new_ks_plist="${new_app_plist}" 202 local new_ks_version 203 if ! new_ks_version="$(defaults read "${new_ks_plist}" \ 204 "${KS_VERSION_KEY}")"; then 205 err "could not read new Keystone version" 206 exit 11 207 fi 208 209 local new_ks_product 210 if ! new_ks_product="$(defaults read "${new_app_plist}" \ 211 "${KS_PRODUCT_KEY}")"; then 212 err "could not read new Keystone product ID" 213 exit 11 214 fi 215 216 if [[ ${old_app_version_build} -lt ${MIN_BUILD} ]] || 217 [[ ${new_app_version_build} -lt ${MIN_BUILD} ]]; then 218 err "old and new versions must be build ${MIN_BUILD} or newer" 219 exit 12 220 fi 221 222 local new_ks_channel 223 new_ks_channel="$(defaults read "${new_app_plist}" \ 224 "${KS_CHANNEL_KEY}" 2> /dev/null || true)" 225 226 local name_extra 227 if [[ "${new_ks_channel}" = "beta" ]]; then 228 name_extra=" Beta" 229 elif [[ "${new_ks_channel}" = "dev" ]]; then 230 name_extra=" Dev" 231 elif [[ "${new_ks_channel}" = "canary" ]]; then 232 name_extra= 233 elif [[ -n "${new_ks_channel}" ]]; then 234 name_extra=" ${new_ks_channel}" 235 fi 236 237 local old_versioned_dir="${old_app_path}/${VERSIONS_DIR}/${old_app_version}" 238 local new_versioned_dir="${new_app_path}/${VERSIONS_DIR}/${new_app_version}" 239 240 if ! cp -p "${SCRIPT_DIR}/keystone_install.sh" \ 241 "${patch_fs}/.keystone_install"; then 242 err "could not copy .keystone_install" 243 exit 13 244 fi 245 246 local patch_keychain_reauthorize_dir="${patch_fs}/.keychain_reauthorize" 247 if ! mkdir "${patch_keychain_reauthorize_dir}"; then 248 err "could not mkdir patch_keychain_reauthorize_dir" 249 exit 13 250 fi 251 252 if ! cp -p "${SCRIPT_DIR}/.keychain_reauthorize/${old_app_bundleid}" \ 253 "${patch_keychain_reauthorize_dir}/${old_app_bundleid}"; then 254 err "could not copy keychain_reauthorize" 255 exit 13 256 fi 257 258 local patch_dotpatch_dir="${patch_fs}/.patch" 259 if ! mkdir "${patch_dotpatch_dir}"; then 260 err "could not mkdir patch_dotpatch_dir" 261 exit 13 262 fi 263 264 if ! cp -p "${SCRIPT_DIR}/dirpatcher.sh" \ 265 "${SCRIPT_DIR}/goobspatch" \ 266 "${SCRIPT_DIR}/liblzma_decompress.dylib" \ 267 "${SCRIPT_DIR}/xzdec" \ 268 "${patch_dotpatch_dir}/"; then 269 err "could not copy patching tools" 270 exit 13 271 fi 272 273 if ! echo "${new_ks_product}" > "${patch_dotpatch_dir}/ks_product" || 274 ! echo "${old_app_version}" > "${patch_dotpatch_dir}/old_app_version" || 275 ! echo "${new_app_version}" > "${patch_dotpatch_dir}/new_app_version" || 276 ! echo "${old_ks_version}" > "${patch_dotpatch_dir}/old_ks_version" || 277 ! echo "${new_ks_version}" > "${patch_dotpatch_dir}/new_ks_version"; then 278 err "could not write patch product or version information" 279 exit 13 280 fi 281 local patch_ks_channel_file="${patch_dotpatch_dir}/ks_channel" 282 if [[ -n "${new_ks_channel}" ]]; then 283 if ! echo "${new_ks_channel}" > "${patch_ks_channel_file}"; then 284 err "could not write Keystone channel information" 285 exit 13 286 fi 287 else 288 if ! touch "${patch_ks_channel_file}"; then 289 err "could not write empty Keystone channel information" 290 exit 13 291 fi 292 fi 293 294 # The only visible contents of the disk image will be a README file that 295 # explains the image's purpose. 296 local new_app_version_extra="${new_app_version}${name_extra}" 297 cat > "${patch_fs}/README.txt" << __EOF__ || \ 298 (err "could not write README.txt" && exit 13) 299 This disk image contains a differential updater that can update 300 ${product_name} from version ${old_app_version} to ${new_app_version_extra}. 301 302 This image is part of the auto-update system and is not independently 303 useful. 304 305 To install ${product_name}, please visit 306 <${product_url}>. 307 __EOF__ 308 309 local patch_versioned_dir="\ 310 ${patch_dotpatch_dir}/version_${old_app_version}_${new_app_version}.dirpatch" 311 312 if ! "${DIRDIFFER}" "${old_versioned_dir}" \ 313 "${new_versioned_dir}" \ 314 "${patch_versioned_dir}"; then 315 local status=${?} 316 err "could not create a dirpatch for the versioned directory" 317 exit $((${status} + 20)) 318 fi 319 320 # Set DIRDIFFER_EXCLUDE to exclude the contents of the Versions directory, 321 # but to include an empty Versions directory. The versioned directory was 322 # already addressed in the preceding dirpatch. 323 export DIRDIFFER_EXCLUDE="/${APP_NAME_RE}/Contents/Versions/" 324 325 # Set DIRDIFFER_NO_DIFF to exclude files introduced by or modified by 326 # Keystone channel and brand tagging and subsequent code signing. 327 export DIRDIFFER_NO_DIFF="\ 328 /${APP_NAME_RE}/Contents/\ 329 (CodeResources|Info\\.plist|MacOS/${product_name}|_CodeSignature/.*)$" 330 331 local patch_app_dir="${patch_dotpatch_dir}/application.dirpatch" 332 333 if ! "${DIRDIFFER}" "${old_app_path}" \ 334 "${new_app_path}" \ 335 "${patch_app_dir}"; then 336 local status=${?} 337 err "could not create a dirpatch for the application directory" 338 exit $((${status} + 40)) 339 fi 340 341 unset DIRDIFFER_EXCLUDE DIRDIFFER_NO_DIFF 342 343 echo "${product_name} ${old_app_version}-${new_app_version_extra} Update" 344 } 345 346 # package_patch_dmg creates a disk image at patch_dmg with the contents of 347 # patch_fs. The disk image's volume name is taken from volume_name. temp_dir 348 # is a work directory such as /tmp for the packager's use. 349 package_patch_dmg() { 350 local patch_fs="${1}" 351 local patch_dmg="${2}" 352 local volume_name="${3}" 353 local temp_dir="${4}" 354 355 # Because most of the contents of ${patch_fs} are already compressed, the 356 # overall compression on the disk image is mostly used to minimize the sizes 357 # of the filesystem structures. In the presence of so much 358 # already-compressed data, zlib performs better than bzip2, so use UDZO. 359 if ! "${PKG_DMG}" \ 360 --verbosity 0 \ 361 --source "${patch_fs}" \ 362 --target "${patch_dmg}" \ 363 --tempdir "${temp_dir}" \ 364 --format UDZO \ 365 --volname "${volume_name}" \ 366 --config "openfolder_bless=0"; then 367 err "disk image creation failed" 368 exit 9 369 fi 370 } 371 372 # make_patch_dmg mounts old_dmg and new_dmg, invokes make_patch_fs to prepare 373 # a patch filesystem, and then hands the patch filesystem to package_patch_dmg 374 # to create patch_dmg. 375 make_patch_dmg() { 376 local product_name="${1}" 377 local old_dmg="${2}" 378 local new_dmg="${3}" 379 local patch_dmg="${4}" 380 381 local temp_dir 382 temp_dir="$(mktemp -d -t "${ME}")" 383 g_cleanup+=("${temp_dir}") 384 385 local old_mount_point="${temp_dir}/old" 386 g_cleanup_mount_points+=("${old_mount_point}") 387 if ! mount_dmg "${old_dmg}" "${old_mount_point}"; then 388 err "could not mount old_dmg ${old_dmg}" 389 exit 6 390 fi 391 392 local new_mount_point="${temp_dir}/new" 393 g_cleanup_mount_points+=("${new_mount_point}") 394 if ! mount_dmg "${new_dmg}" "${new_mount_point}"; then 395 err "could not mount new_dmg ${new_dmg}" 396 exit 7 397 fi 398 399 local patch_fs="${temp_dir}/patch" 400 if ! mkdir "${patch_fs}"; then 401 err "could not mkdir patch_fs ${patch_fs}" 402 exit 8 403 fi 404 405 local volume_name 406 volume_name="$(make_patch_fs "${product_name}" \ 407 "${old_mount_point}" \ 408 "${new_mount_point}" \ 409 "${patch_fs}")" 410 411 hdiutil detach "${new_mount_point}" > /dev/null 412 unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}] 413 414 hdiutil detach "${old_mount_point}" > /dev/null 415 unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}] 416 417 package_patch_dmg "${patch_fs}" "${patch_dmg}" "${volume_name}" "${temp_dir}" 418 419 rm -rf "${temp_dir}" 420 unset g_cleanup[${#g_cleanup[@]}] 421 } 422 423 # shell_safe_path ensures that |path| is safe to pass to tools as a 424 # command-line argument. If the first character in |path| is "-", "./" is 425 # prepended to it. The possibly-modified |path| is output. 426 shell_safe_path() { 427 local path="${1}" 428 if [[ "${path:0:1}" = "-" ]]; then 429 echo "./${path}" 430 else 431 echo "${path}" 432 fi 433 } 434 435 usage() { 436 echo "usage: ${ME} product_name old_dmg new_dmg patch_dmg" >& 2 437 } 438 439 main() { 440 local product_name old_dmg new_dmg patch_dmg 441 product_name="${1}" 442 old_dmg="$(shell_safe_path "${2}")" 443 new_dmg="$(shell_safe_path "${3}")" 444 patch_dmg="$(shell_safe_path "${4}")" 445 446 trap cleanup EXIT HUP INT QUIT TERM 447 448 if ! [[ -f "${old_dmg}" ]] || ! [[ -f "${new_dmg}" ]]; then 449 err "old_dmg and new_dmg must exist and be files" 450 usage 451 exit 3 452 fi 453 454 if [[ -e "${patch_dmg}" ]]; then 455 err "patch_dmg must not exist" 456 usage 457 exit 4 458 fi 459 460 local patch_dmg_parent 461 patch_dmg_parent="$(dirname "${patch_dmg}")" 462 if ! [[ -d "${patch_dmg_parent}" ]]; then 463 err "patch_dmg parent directory must exist and be a directory" 464 usage 465 exit 5 466 fi 467 468 make_patch_dmg "${product_name}" "${old_dmg}" "${new_dmg}" "${patch_dmg}" 469 470 trap - EXIT 471 } 472 473 if [[ ${#} -ne 4 ]]; then 474 usage 475 exit 2 476 fi 477 478 main "${@}" 479 exit ${?} 480