summaryrefslogtreecommitdiffstats
path: root/chrome/installer/mac/dmgdiffer.sh
blob: dbdeb9b34e858c0bd057fa1354557f5f3440d9a3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
#!/bin/bash -p

# Copyright (c) 2012 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: dmgdiffer.sh product_name old_dmg new_dmg patch_dmg
#
# dmgdiffer creates a disk image containing a binary update able to patch
# a product originally distributed in old_dmg to the version in new_dmg. Much
# of this script is generic, but the make_patch_fs function is specific to
# a product: in this case, Google Chrome.
#
# This script operates by mounting old_dmg and new_dmg, creating a new
# filesystem structure containing dirpatches generated by dirdiffer and
# goobsdiff (which should be located in the same directory as this script),
# and producing a disk image from that structure.
#
# The Chrome make_patch_fs function produces an disk image that is able to
# update a single old version on any Keystone channel to a new version on a
# specific Keystone channel (the Keystone channel associated with new_dmg).
# Chrome's updates are split into two dirpatches: one updates the old
# versioned directory to the new one, and the other updates the remainder of
# the application. The versioned directory is split out from the rest because
# it contains the bulk of the application and its name changes from version to
# version, and dirdiffer/dirpatcher do not directly handle name changes. This
# approach also allows the versioned directory dirpatch to be applied in-place
# in most cases during an update, rather than relying on a temporary
# directory. In order to allow a single update dmg to apply to an old version
# on any Keystone channel, several small files are never distributed as diffs,
# and only as full (possibly compressed) versions of the new files. These
# files include the outer application's Info.plist which contains Keystone
# channel information, and anything created or modified by code-signing the
# outer application.
#
# Application of update disk images produced by this script is
# product-specific. With updates managed by Keystone, the update disk images
# can contain a .keystone_install script that is able to locate and update
# the installed product.
#
# Exit codes:
#  0  OK
#  1  Unknown failure
#  2  Incorrect number of parameters
#  3  Input disk images do not exist
#  4  Output disk image already exists
#  5  Parent of output directory does not exist or is not a directory
#  6  Could not mount old_dmg
#  7  Could not mount new_dmg
#  8  Could not create temporary patch filesystem directory
#  9  Could not create disk image
# 10  Could not read old application data
# 11  Could not read new application data
# 12  Old or new application sanity check failure
# 13  Could not write the patch
#
# Exit codes in the range 21-40 are mapped to codes 1-20 as returned by the
# first dirdiffer invocation. Codes 41-60 are mapped to codes 1-20 as returned
# by the second.

set -eu

# Environment sanitization. Set a known-safe PATH. Clear environment variables
# that might impact the interpreter's operation. The |bash -p| invocation
# on the #! line takes the bite out of BASH_ENV, ENV, and SHELLOPTS (among
# other features), but clearing them here ensures that they won't impact any
# shell scripts used as utility programs. SHELLOPTS is read-only and can't be
# unset, only unexported.
export PATH="/usr/bin:/bin:/usr/sbin:/sbin"
unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT
export -n SHELLOPTS

ME="$(basename "${0}")"
readonly ME
SCRIPT_DIR="$(dirname "${0}")"
readonly SCRIPT_DIR
readonly DIRDIFFER="${SCRIPT_DIR}/dirdiffer.sh"
readonly PKG_DMG="${SCRIPT_DIR}/pkg-dmg"

err() {
  local error="${1}"

  echo "${ME}: ${error}" >& 2
}

declare -a g_cleanup g_cleanup_mount_points
cleanup() {
  local status=${?}

  trap - EXIT
  trap '' HUP INT QUIT TERM

  if [[ ${status} -ge 128 ]]; then
    err "Caught signal $((${status} - 128))"
  fi

  if [[ "${#g_cleanup_mount_points[@]}" -gt 0 ]]; then
    local mount_point
    for mount_point in "${g_cleanup_mount_points[@]}"; do
      hdiutil detach "${mount_point}" -force >& /dev/null || true
    done
  fi

  if [[ "${#g_cleanup[@]}" -gt 0 ]]; then
    rm -rf "${g_cleanup[@]}"
  fi

  exit ${status}
}

mount_dmg() {
  local dmg="${1}"
  local mount_point="${2}"

  if ! hdiutil attach "${1}" -mountpoint "${2}" \
                             -nobrowse -owners off > /dev/null; then
    # set -e is in effect. return ${?} so that the caller can check the return
    # code if desired, perhaps to print a more useful error message or to exit
    # with a more precise status than would be possible here.
    return ${?}
  fi
}

# make_patch_fs is responsible for comparing the old and new disk images
# mounted at old_fs and new_fs, respectively, and populating patch_fs with the
# contents of what will become a disk image able to update old_fs to new_fs.
# It then outputs a string which will be used as the volume name of the
# patch_dmg.
#
# The entire patch contents are placed into a .patch directory to hide them
# from ordinary view. The disk image will be given a volume name like
# "Google Chrome 5.0.375.55-5.0.375.70" as an identifying aid, although
# uniqueness is not important and users will never interact directly with
# them.
make_patch_fs() {
  local product_name="${1}"
  local old_fs="${2}"
  local new_fs="${3}"
  local patch_fs="${4}"

  readonly APP_NAME="${product_name}.app"
  readonly APP_NAME_RE="${product_name}\\.app"
  readonly APP_PLIST="Contents/Info"
  readonly APP_VERSION_KEY="CFBundleShortVersionString"
  readonly APP_BUNDLEID_KEY="CFBundleIdentifier"
  readonly KS_VERSION_KEY="KSVersion"
  readonly KS_PRODUCT_KEY="KSProductID"
  readonly KS_CHANNEL_KEY="KSChannelID"
  readonly VERSIONS_DIR="Contents/Versions"
  readonly BUILD_RE="^[0-9]+\\.[0-9]+\\.([0-9]+)\\.[0-9]+\$"
  readonly MIN_BUILD=434

  local product_url="http://www.google.com/chrome/"
  if [[ "${product_name}" = "Google Chrome Canary" ]]; then
    product_url="http://tools.google.com/dlpage/chromesxs"
  fi

  local old_app_path="${old_fs}/${APP_NAME}"
  local old_app_plist="${old_app_path}/${APP_PLIST}"
  local old_app_version
  if ! old_app_version="$(defaults read "${old_app_plist}" \
                                        "${APP_VERSION_KEY}")"; then
    err "could not read old app version"
    exit 10
  fi
  if ! [[ "${old_app_version}" =~ ${BUILD_RE} ]]; then
    err "old app version not of expected format"
    exit 10
  fi
  local old_app_version_build="${BASH_REMATCH[1]}"

  local old_app_bundleid
  if ! old_app_bundleid="$(defaults read "${old_app_plist}" \
                                         "${APP_BUNDLEID_KEY}")"; then
    err "could not read old app bundle ID"
    exit 10
  fi

  local old_ks_plist="${old_app_plist}"
  local old_ks_version
  if ! old_ks_version="$(defaults read "${old_ks_plist}" \
                                       "${KS_VERSION_KEY}")"; then
    err "could not read old Keystone version"
    exit 10
  fi

  local new_app_path="${new_fs}/${APP_NAME}"
  local new_app_plist="${new_app_path}/${APP_PLIST}"
  local new_app_version
  if ! new_app_version="$(defaults read "${new_app_plist}" \
                      "${APP_VERSION_KEY}")"; then
    err "could not read new app version"
    exit 11
  fi
  if ! [[ "${new_app_version}" =~ ${BUILD_RE} ]]; then
    err "new app version not of expected format"
    exit 11
  fi
  local new_app_version_build="${BASH_REMATCH[1]}"

  local new_ks_plist="${new_app_plist}"
  local new_ks_version
  if ! new_ks_version="$(defaults read "${new_ks_plist}" \
                                       "${KS_VERSION_KEY}")"; then
    err "could not read new Keystone version"
    exit 11
  fi

  local new_ks_product
  if ! new_ks_product="$(defaults read "${new_app_plist}" \
                                       "${KS_PRODUCT_KEY}")"; then
    err "could not read new Keystone product ID"
    exit 11
  fi

  if [[ ${old_app_version_build} -lt ${MIN_BUILD} ]] ||
     [[ ${new_app_version_build} -lt ${MIN_BUILD} ]]; then
    err "old and new versions must be build ${MIN_BUILD} or newer"
    exit 12
  fi

  local new_ks_channel
  new_ks_channel="$(defaults read "${new_app_plist}" \
                    "${KS_CHANNEL_KEY}" 2> /dev/null || true)"

  local name_extra
  if [[ "${new_ks_channel}" = "beta" ]]; then
    name_extra=" Beta"
  elif [[ "${new_ks_channel}" = "dev" ]]; then
    name_extra=" Dev"
  elif [[ "${new_ks_channel}" = "canary" ]]; then
    name_extra=
  elif [[ -n "${new_ks_channel}" ]]; then
    name_extra=" ${new_ks_channel}"
  fi

  local old_versioned_dir="${old_app_path}/${VERSIONS_DIR}/${old_app_version}"
  local new_versioned_dir="${new_app_path}/${VERSIONS_DIR}/${new_app_version}"

  if ! cp -p "${SCRIPT_DIR}/keystone_install.sh" \
             "${patch_fs}/.keystone_install"; then
    err "could not copy .keystone_install"
    exit 13
  fi

  local patch_dotpatch_dir="${patch_fs}/.patch"
  if ! mkdir "${patch_dotpatch_dir}"; then
    err "could not mkdir patch_dotpatch_dir"
    exit 13
  fi

  if ! cp -p "${SCRIPT_DIR}/dirpatcher.sh" \
             "${SCRIPT_DIR}/goobspatch" \
             "${SCRIPT_DIR}/liblzma_decompress.dylib" \
             "${SCRIPT_DIR}/xzdec" \
             "${patch_dotpatch_dir}/"; then
    err "could not copy patching tools"
    exit 13
  fi

  if ! echo "${new_ks_product}" > "${patch_dotpatch_dir}/ks_product" ||
     ! echo "${old_app_version}" > "${patch_dotpatch_dir}/old_app_version" ||
     ! echo "${new_app_version}" > "${patch_dotpatch_dir}/new_app_version" ||
     ! echo "${old_ks_version}" > "${patch_dotpatch_dir}/old_ks_version" ||
     ! echo "${new_ks_version}" > "${patch_dotpatch_dir}/new_ks_version"; then
    err "could not write patch product or version information"
    exit 13
  fi
  local patch_ks_channel_file="${patch_dotpatch_dir}/ks_channel"
  if [[ -n "${new_ks_channel}" ]]; then
    if ! echo "${new_ks_channel}" > "${patch_ks_channel_file}"; then
      err "could not write Keystone channel information"
      exit 13
    fi
  else
    if ! touch "${patch_ks_channel_file}"; then
      err "could not write empty Keystone channel information"
      exit 13
    fi
  fi

  # The only visible contents of the disk image will be a README file that
  # explains the image's purpose.
  local new_app_version_extra="${new_app_version}${name_extra}"
  cat > "${patch_fs}/README.txt" << __EOF__ || \
      (err "could not write README.txt" && exit 13)
This disk image contains a differential updater that can update
${product_name} from version ${old_app_version} to ${new_app_version_extra}.

This image is part of the auto-update system and is not independently
useful.

To install ${product_name}, please visit
<${product_url}>.
__EOF__

  local patch_versioned_dir="\
${patch_dotpatch_dir}/version_${old_app_version}_${new_app_version}.dirpatch"

  if ! "${DIRDIFFER}" "${old_versioned_dir}" \
                      "${new_versioned_dir}" \
                      "${patch_versioned_dir}"; then
    local status=${?}
    err "could not create a dirpatch for the versioned directory"
    exit $((${status} + 20))
  fi

  # Set DIRDIFFER_EXCLUDE to exclude the contents of the Versions directory,
  # but to include an empty Versions directory. The versioned directory was
  # already addressed in the preceding dirpatch.
  export DIRDIFFER_EXCLUDE="/${APP_NAME_RE}/Contents/Versions/"

  # Set DIRDIFFER_NO_DIFF to exclude files introduced by or modified by
  # Keystone channel and brand tagging and subsequent code signing.
  export DIRDIFFER_NO_DIFF="\
/${APP_NAME_RE}/Contents/\
(CodeResources|Info\\.plist|MacOS/${product_name}|_CodeSignature/.*)$"

  local patch_app_dir="${patch_dotpatch_dir}/application.dirpatch"

  if ! "${DIRDIFFER}" "${old_app_path}" \
                      "${new_app_path}" \
                      "${patch_app_dir}"; then
    local status=${?}
    err "could not create a dirpatch for the application directory"
    exit $((${status} + 40))
  fi

  unset DIRDIFFER_EXCLUDE DIRDIFFER_NO_DIFF

  echo "${product_name} ${old_app_version}-${new_app_version_extra} Update"
}

# package_patch_dmg creates a disk image at patch_dmg with the contents of
# patch_fs. The disk image's volume name is taken from volume_name. temp_dir
# is a work directory such as /tmp for the packager's use.
package_patch_dmg() {
  local patch_fs="${1}"
  local patch_dmg="${2}"
  local volume_name="${3}"
  local temp_dir="${4}"

  # Because most of the contents of ${patch_fs} are already compressed, the
  # overall compression on the disk image is mostly used to minimize the sizes
  # of the filesystem structures. In the presence of so much
  # already-compressed data, zlib performs better than bzip2, so use UDZO.
  if ! "${PKG_DMG}" \
           --verbosity 0 \
           --source "${patch_fs}" \
           --target "${patch_dmg}" \
           --tempdir "${temp_dir}" \
           --format UDZO \
           --volname "${volume_name}" \
           --config "openfolder_bless=0"; then
    err "disk image creation failed"
    exit 9
  fi
}

# make_patch_dmg mounts old_dmg and new_dmg, invokes make_patch_fs to prepare
# a patch filesystem, and then hands the patch filesystem to package_patch_dmg
# to create patch_dmg.
make_patch_dmg() {
  local product_name="${1}"
  local old_dmg="${2}"
  local new_dmg="${3}"
  local patch_dmg="${4}"

  local temp_dir
  temp_dir="$(mktemp -d -t "${ME}")"
  g_cleanup+=("${temp_dir}")

  local old_mount_point="${temp_dir}/old"
  g_cleanup_mount_points+=("${old_mount_point}")
  if ! mount_dmg "${old_dmg}" "${old_mount_point}"; then
    err "could not mount old_dmg ${old_dmg}"
    exit 6
  fi

  local new_mount_point="${temp_dir}/new"
  g_cleanup_mount_points+=("${new_mount_point}")
  if ! mount_dmg "${new_dmg}" "${new_mount_point}"; then
    err "could not mount new_dmg ${new_dmg}"
    exit 7
  fi

  local patch_fs="${temp_dir}/patch"
  if ! mkdir "${patch_fs}"; then
    err "could not mkdir patch_fs ${patch_fs}"
    exit 8
  fi

  local volume_name
  volume_name="$(make_patch_fs "${product_name}" \
                               "${old_mount_point}" \
                               "${new_mount_point}" \
                               "${patch_fs}")"

  hdiutil detach "${new_mount_point}" > /dev/null
  unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}]

  hdiutil detach "${old_mount_point}" > /dev/null
  unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}]

  package_patch_dmg "${patch_fs}" "${patch_dmg}" "${volume_name}" "${temp_dir}"

  rm -rf "${temp_dir}"
  unset g_cleanup[${#g_cleanup[@]}]
}

# shell_safe_path ensures that |path| is safe to pass to tools as a
# command-line argument. If the first character in |path| is "-", "./" is
# prepended to it. The possibly-modified |path| is output.
shell_safe_path() {
  local path="${1}"
  if [[ "${path:0:1}" = "-" ]]; then
    echo "./${path}"
  else
    echo "${path}"
  fi
}

usage() {
  echo "usage: ${ME} product_name old_dmg new_dmg patch_dmg" >& 2
}

main() {
  local product_name old_dmg new_dmg patch_dmg
  product_name="${1}"
  old_dmg="$(shell_safe_path "${2}")"
  new_dmg="$(shell_safe_path "${3}")"
  patch_dmg="$(shell_safe_path "${4}")"

  trap cleanup EXIT HUP INT QUIT TERM

  if ! [[ -f "${old_dmg}" ]] || ! [[ -f "${new_dmg}" ]]; then
    err "old_dmg and new_dmg must exist and be files"
    usage
    exit 3
  fi

  if [[ -e "${patch_dmg}" ]]; then
    err "patch_dmg must not exist"
    usage
    exit 4
  fi

  local patch_dmg_parent
  patch_dmg_parent="$(dirname "${patch_dmg}")"
  if ! [[ -d "${patch_dmg_parent}" ]]; then
    err "patch_dmg parent directory must exist and be a directory"
    usage
    exit 5
  fi

  make_patch_dmg "${product_name}" "${old_dmg}" "${new_dmg}" "${patch_dmg}"

  trap - EXIT
}

if [[ ${#} -ne 4 ]]; then
  usage
  exit 2
fi

main "${@}"
exit ${?}