dmgdiffer.sh revision 5821806d5e7f356e8fa4b058a389a808ea183019
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
61set -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.
69export PATH="/usr/bin:/bin:/usr/sbin:/sbin"
70unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT
71export -n SHELLOPTS
72
73ME="$(basename "${0}")"
74readonly ME
75SCRIPT_DIR="$(dirname "${0}")"
76readonly SCRIPT_DIR
77readonly DIRDIFFER="${SCRIPT_DIR}/dirdiffer.sh"
78readonly PKG_DMG="${SCRIPT_DIR}/pkg-dmg"
79
80err() {
81  local error="${1}"
82
83  echo "${ME}: ${error}" >& 2
84}
85
86declare -a g_cleanup g_cleanup_mount_points
87cleanup() {
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
111mount_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.
135make_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)
299This disk image contains a differential updater that can update
300${product_name} from version ${old_app_version} to ${new_app_version_extra}.
301
302This image is part of the auto-update system and is not independently
303useful.
304
305To 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.
349package_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.
375make_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.
426shell_safe_path() {
427  local path="${1}"
428  if [[ "${path:0:1}" = "-" ]]; then
429    echo "./${path}"
430  else
431    echo "${path}"
432  fi
433}
434
435usage() {
436  echo "usage: ${ME} product_name old_dmg new_dmg patch_dmg" >& 2
437}
438
439main() {
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
473if [[ ${#} -ne 4 ]]; then
474  usage
475  exit 2
476fi
477
478main "${@}"
479exit ${?}
480