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_dotpatch_dir="${patch_fs}/.patch"
247  if ! mkdir "${patch_dotpatch_dir}"; then
248    err "could not mkdir patch_dotpatch_dir"
249    exit 13
250  fi
251
252  if ! cp -p "${SCRIPT_DIR}/dirpatcher.sh" \
253             "${SCRIPT_DIR}/goobspatch" \
254             "${SCRIPT_DIR}/liblzma_decompress.dylib" \
255             "${SCRIPT_DIR}/xzdec" \
256             "${patch_dotpatch_dir}/"; then
257    err "could not copy patching tools"
258    exit 13
259  fi
260
261  if ! echo "${new_ks_product}" > "${patch_dotpatch_dir}/ks_product" ||
262     ! echo "${old_app_version}" > "${patch_dotpatch_dir}/old_app_version" ||
263     ! echo "${new_app_version}" > "${patch_dotpatch_dir}/new_app_version" ||
264     ! echo "${old_ks_version}" > "${patch_dotpatch_dir}/old_ks_version" ||
265     ! echo "${new_ks_version}" > "${patch_dotpatch_dir}/new_ks_version"; then
266    err "could not write patch product or version information"
267    exit 13
268  fi
269  local patch_ks_channel_file="${patch_dotpatch_dir}/ks_channel"
270  if [[ -n "${new_ks_channel}" ]]; then
271    if ! echo "${new_ks_channel}" > "${patch_ks_channel_file}"; then
272      err "could not write Keystone channel information"
273      exit 13
274    fi
275  else
276    if ! touch "${patch_ks_channel_file}"; then
277      err "could not write empty Keystone channel information"
278      exit 13
279    fi
280  fi
281
282  # The only visible contents of the disk image will be a README file that
283  # explains the image's purpose.
284  local new_app_version_extra="${new_app_version}${name_extra}"
285  cat > "${patch_fs}/README.txt" << __EOF__ || \
286      (err "could not write README.txt" && exit 13)
287This disk image contains a differential updater that can update
288${product_name} from version ${old_app_version} to ${new_app_version_extra}.
289
290This image is part of the auto-update system and is not independently
291useful.
292
293To install ${product_name}, please visit
294<${product_url}>.
295__EOF__
296
297  local patch_versioned_dir="\
298${patch_dotpatch_dir}/version_${old_app_version}_${new_app_version}.dirpatch"
299
300  if ! "${DIRDIFFER}" "${old_versioned_dir}" \
301                      "${new_versioned_dir}" \
302                      "${patch_versioned_dir}"; then
303    local status=${?}
304    err "could not create a dirpatch for the versioned directory"
305    exit $((${status} + 20))
306  fi
307
308  # Set DIRDIFFER_EXCLUDE to exclude the contents of the Versions directory,
309  # but to include an empty Versions directory. The versioned directory was
310  # already addressed in the preceding dirpatch.
311  export DIRDIFFER_EXCLUDE="/${APP_NAME_RE}/Contents/Versions/"
312
313  # Set DIRDIFFER_NO_DIFF to exclude files introduced by or modified by
314  # Keystone channel and brand tagging and subsequent code signing.
315  export DIRDIFFER_NO_DIFF="\
316/${APP_NAME_RE}/Contents/\
317(CodeResources|Info\\.plist|MacOS/${product_name}|_CodeSignature/.*)$"
318
319  local patch_app_dir="${patch_dotpatch_dir}/application.dirpatch"
320
321  if ! "${DIRDIFFER}" "${old_app_path}" \
322                      "${new_app_path}" \
323                      "${patch_app_dir}"; then
324    local status=${?}
325    err "could not create a dirpatch for the application directory"
326    exit $((${status} + 40))
327  fi
328
329  unset DIRDIFFER_EXCLUDE DIRDIFFER_NO_DIFF
330
331  echo "${product_name} ${old_app_version}-${new_app_version_extra} Update"
332}
333
334# package_patch_dmg creates a disk image at patch_dmg with the contents of
335# patch_fs. The disk image's volume name is taken from volume_name. temp_dir
336# is a work directory such as /tmp for the packager's use.
337package_patch_dmg() {
338  local patch_fs="${1}"
339  local patch_dmg="${2}"
340  local volume_name="${3}"
341  local temp_dir="${4}"
342
343  # Because most of the contents of ${patch_fs} are already compressed, the
344  # overall compression on the disk image is mostly used to minimize the sizes
345  # of the filesystem structures. In the presence of so much
346  # already-compressed data, zlib performs better than bzip2, so use UDZO.
347  if ! "${PKG_DMG}" \
348           --verbosity 0 \
349           --source "${patch_fs}" \
350           --target "${patch_dmg}" \
351           --tempdir "${temp_dir}" \
352           --format UDZO \
353           --volname "${volume_name}" \
354           --config "openfolder_bless=0"; then
355    err "disk image creation failed"
356    exit 9
357  fi
358}
359
360# make_patch_dmg mounts old_dmg and new_dmg, invokes make_patch_fs to prepare
361# a patch filesystem, and then hands the patch filesystem to package_patch_dmg
362# to create patch_dmg.
363make_patch_dmg() {
364  local product_name="${1}"
365  local old_dmg="${2}"
366  local new_dmg="${3}"
367  local patch_dmg="${4}"
368
369  local temp_dir
370  temp_dir="$(mktemp -d -t "${ME}")"
371  g_cleanup+=("${temp_dir}")
372
373  local old_mount_point="${temp_dir}/old"
374  g_cleanup_mount_points+=("${old_mount_point}")
375  if ! mount_dmg "${old_dmg}" "${old_mount_point}"; then
376    err "could not mount old_dmg ${old_dmg}"
377    exit 6
378  fi
379
380  local new_mount_point="${temp_dir}/new"
381  g_cleanup_mount_points+=("${new_mount_point}")
382  if ! mount_dmg "${new_dmg}" "${new_mount_point}"; then
383    err "could not mount new_dmg ${new_dmg}"
384    exit 7
385  fi
386
387  local patch_fs="${temp_dir}/patch"
388  if ! mkdir "${patch_fs}"; then
389    err "could not mkdir patch_fs ${patch_fs}"
390    exit 8
391  fi
392
393  local volume_name
394  volume_name="$(make_patch_fs "${product_name}" \
395                               "${old_mount_point}" \
396                               "${new_mount_point}" \
397                               "${patch_fs}")"
398
399  hdiutil detach "${new_mount_point}" > /dev/null
400  unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}]
401
402  hdiutil detach "${old_mount_point}" > /dev/null
403  unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}]
404
405  package_patch_dmg "${patch_fs}" "${patch_dmg}" "${volume_name}" "${temp_dir}"
406
407  rm -rf "${temp_dir}"
408  unset g_cleanup[${#g_cleanup[@]}]
409}
410
411# shell_safe_path ensures that |path| is safe to pass to tools as a
412# command-line argument. If the first character in |path| is "-", "./" is
413# prepended to it. The possibly-modified |path| is output.
414shell_safe_path() {
415  local path="${1}"
416  if [[ "${path:0:1}" = "-" ]]; then
417    echo "./${path}"
418  else
419    echo "${path}"
420  fi
421}
422
423usage() {
424  echo "usage: ${ME} product_name old_dmg new_dmg patch_dmg" >& 2
425}
426
427main() {
428  local product_name old_dmg new_dmg patch_dmg
429  product_name="${1}"
430  old_dmg="$(shell_safe_path "${2}")"
431  new_dmg="$(shell_safe_path "${3}")"
432  patch_dmg="$(shell_safe_path "${4}")"
433
434  trap cleanup EXIT HUP INT QUIT TERM
435
436  if ! [[ -f "${old_dmg}" ]] || ! [[ -f "${new_dmg}" ]]; then
437    err "old_dmg and new_dmg must exist and be files"
438    usage
439    exit 3
440  fi
441
442  if [[ -e "${patch_dmg}" ]]; then
443    err "patch_dmg must not exist"
444    usage
445    exit 4
446  fi
447
448  local patch_dmg_parent
449  patch_dmg_parent="$(dirname "${patch_dmg}")"
450  if ! [[ -d "${patch_dmg_parent}" ]]; then
451    err "patch_dmg parent directory must exist and be a directory"
452    usage
453    exit 5
454  fi
455
456  make_patch_dmg "${product_name}" "${old_dmg}" "${new_dmg}" "${patch_dmg}"
457
458  trap - EXIT
459}
460
461if [[ ${#} -ne 4 ]]; then
462  usage
463  exit 2
464fi
465
466main "${@}"
467exit ${?}
468