• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2#
3# Copyright (C) 2017 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18"""Send an A/B update to an Android device over adb."""
19
20from __future__ import print_function
21from __future__ import absolute_import
22
23import argparse
24import binascii
25import hashlib
26import logging
27import os
28import socket
29import subprocess
30import sys
31import struct
32import tempfile
33import time
34import threading
35import xml.etree.ElementTree
36import zipfile
37
38from six.moves import BaseHTTPServer
39
40import update_payload.payload
41
42
43# The path used to store the OTA package when applying the package from a file.
44OTA_PACKAGE_PATH = '/data/ota_package'
45
46# The path to the payload public key on the device.
47PAYLOAD_KEY_PATH = '/etc/update_engine/update-payload-key.pub.pem'
48
49# The port on the device that update_engine should connect to.
50DEVICE_PORT = 1234
51
52
53def CopyFileObjLength(fsrc, fdst, buffer_size=128 * 1024, copy_length=None):
54  """Copy from a file object to another.
55
56  This function is similar to shutil.copyfileobj except that it allows to copy
57  less than the full source file.
58
59  Args:
60    fsrc: source file object where to read from.
61    fdst: destination file object where to write to.
62    buffer_size: size of the copy buffer in memory.
63    copy_length: maximum number of bytes to copy, or None to copy everything.
64
65  Returns:
66    the number of bytes copied.
67  """
68  copied = 0
69  while True:
70    chunk_size = buffer_size
71    if copy_length is not None:
72      chunk_size = min(chunk_size, copy_length - copied)
73      if not chunk_size:
74        break
75    buf = fsrc.read(chunk_size)
76    if not buf:
77      break
78    fdst.write(buf)
79    copied += len(buf)
80  return copied
81
82
83class AndroidOTAPackage(object):
84  """Android update payload using the .zip format.
85
86  Android OTA packages traditionally used a .zip file to store the payload. When
87  applying A/B updates over the network, a payload binary is stored RAW inside
88  this .zip file which is used by update_engine to apply the payload. To do
89  this, an offset and size inside the .zip file are provided.
90  """
91
92  # Android OTA package file paths.
93  OTA_PAYLOAD_BIN = 'payload.bin'
94  OTA_PAYLOAD_PROPERTIES_TXT = 'payload_properties.txt'
95  SECONDARY_OTA_PAYLOAD_BIN = 'secondary/payload.bin'
96  SECONDARY_OTA_PAYLOAD_PROPERTIES_TXT = 'secondary/payload_properties.txt'
97  PAYLOAD_MAGIC_HEADER = b'CrAU'
98
99  def __init__(self, otafilename, secondary_payload=False):
100    self.otafilename = otafilename
101
102    otazip = zipfile.ZipFile(otafilename, 'r')
103    payload_entry = (self.SECONDARY_OTA_PAYLOAD_BIN if secondary_payload else
104                     self.OTA_PAYLOAD_BIN)
105    payload_info = otazip.getinfo(payload_entry)
106
107    if payload_info.compress_type != 0:
108      logging.error(
109          "Expected payload to be uncompressed, got compression method %d",
110          payload_info.compress_type)
111    # Don't use len(payload_info.extra). Because that returns size of extra
112    # fields in central directory. We need to look at local file directory,
113    # as these two might have different sizes.
114    with open(otafilename, "rb") as fp:
115      fp.seek(payload_info.header_offset)
116      data = fp.read(zipfile.sizeFileHeader)
117      fheader = struct.unpack(zipfile.structFileHeader, data)
118      # Last two fields of local file header are filename length and
119      # extra length
120      filename_len = fheader[-2]
121      extra_len = fheader[-1]
122      self.offset = payload_info.header_offset
123      self.offset += zipfile.sizeFileHeader
124      self.offset += filename_len + extra_len
125      self.size = payload_info.file_size
126      fp.seek(self.offset)
127      payload_header = fp.read(4)
128      if payload_header != self.PAYLOAD_MAGIC_HEADER:
129        logging.warning(
130            "Invalid header, expected %s, got %s."
131            "Either the offset is not correct, or payload is corrupted",
132            binascii.hexlify(self.PAYLOAD_MAGIC_HEADER),
133            binascii.hexlify(payload_header))
134
135    property_entry = (self.SECONDARY_OTA_PAYLOAD_PROPERTIES_TXT if
136                      secondary_payload else self.OTA_PAYLOAD_PROPERTIES_TXT)
137    self.properties = otazip.read(property_entry)
138
139
140class UpdateHandler(BaseHTTPServer.BaseHTTPRequestHandler):
141  """A HTTPServer that supports single-range requests.
142
143  Attributes:
144    serving_payload: path to the only payload file we are serving.
145    serving_range: the start offset and size tuple of the payload.
146  """
147
148  @staticmethod
149  def _parse_range(range_str, file_size):
150    """Parse an HTTP range string.
151
152    Args:
153      range_str: HTTP Range header in the request, not including "Header:".
154      file_size: total size of the serving file.
155
156    Returns:
157      A tuple (start_range, end_range) with the range of bytes requested.
158    """
159    start_range = 0
160    end_range = file_size
161
162    if range_str:
163      range_str = range_str.split('=', 1)[1]
164      s, e = range_str.split('-', 1)
165      if s:
166        start_range = int(s)
167        if e:
168          end_range = int(e) + 1
169      elif e:
170        if int(e) < file_size:
171          start_range = file_size - int(e)
172    return start_range, end_range
173
174  def do_GET(self):  # pylint: disable=invalid-name
175    """Reply with the requested payload file."""
176    if self.path != '/payload':
177      self.send_error(404, 'Unknown request')
178      return
179
180    if not self.serving_payload:
181      self.send_error(500, 'No serving payload set')
182      return
183
184    try:
185      f = open(self.serving_payload, 'rb')
186    except IOError:
187      self.send_error(404, 'File not found')
188      return
189    # Handle the range request.
190    if 'Range' in self.headers:
191      self.send_response(206)
192    else:
193      self.send_response(200)
194
195    serving_start, serving_size = self.serving_range
196    start_range, end_range = self._parse_range(self.headers.get('range'),
197                                               serving_size)
198    logging.info('Serving request for %s from %s [%d, %d) length: %d',
199                 self.path, self.serving_payload, serving_start + start_range,
200                 serving_start + end_range, end_range - start_range)
201
202    self.send_header('Accept-Ranges', 'bytes')
203    self.send_header('Content-Range',
204                     'bytes ' + str(start_range) + '-' + str(end_range - 1) +
205                     '/' + str(end_range - start_range))
206    self.send_header('Content-Length', end_range - start_range)
207
208    stat = os.fstat(f.fileno())
209    self.send_header('Last-Modified', self.date_time_string(stat.st_mtime))
210    self.send_header('Content-type', 'application/octet-stream')
211    self.end_headers()
212
213    f.seek(serving_start + start_range)
214    CopyFileObjLength(f, self.wfile, copy_length=end_range - start_range)
215
216  def do_POST(self):  # pylint: disable=invalid-name
217    """Reply with the omaha response xml."""
218    if self.path != '/update':
219      self.send_error(404, 'Unknown request')
220      return
221
222    if not self.serving_payload:
223      self.send_error(500, 'No serving payload set')
224      return
225
226    try:
227      f = open(self.serving_payload, 'rb')
228    except IOError:
229      self.send_error(404, 'File not found')
230      return
231
232    content_length = int(self.headers.getheader('Content-Length'))
233    request_xml = self.rfile.read(content_length)
234    xml_root = xml.etree.ElementTree.fromstring(request_xml)
235    appid = None
236    for app in xml_root.iter('app'):
237      if 'appid' in app.attrib:
238        appid = app.attrib['appid']
239        break
240    if not appid:
241      self.send_error(400, 'No appid in Omaha request')
242      return
243
244    self.send_response(200)
245    self.send_header("Content-type", "text/xml")
246    self.end_headers()
247
248    serving_start, serving_size = self.serving_range
249    sha256 = hashlib.sha256()
250    f.seek(serving_start)
251    bytes_to_hash = serving_size
252    while bytes_to_hash:
253      buf = f.read(min(bytes_to_hash, 1024 * 1024))
254      if not buf:
255        self.send_error(500, 'Payload too small')
256        return
257      sha256.update(buf)
258      bytes_to_hash -= len(buf)
259
260    payload = update_payload.Payload(f, payload_file_offset=serving_start)
261    payload.Init()
262
263    response_xml = '''
264        <?xml version="1.0" encoding="UTF-8"?>
265        <response protocol="3.0">
266          <app appid="{appid}">
267            <updatecheck status="ok">
268              <urls>
269                <url codebase="http://127.0.0.1:{port}/"/>
270              </urls>
271              <manifest version="0.0.0.1">
272                <actions>
273                  <action event="install" run="payload"/>
274                  <action event="postinstall" MetadataSize="{metadata_size}"/>
275                </actions>
276                <packages>
277                  <package hash_sha256="{payload_hash}" name="payload" size="{payload_size}"/>
278                </packages>
279              </manifest>
280            </updatecheck>
281          </app>
282        </response>
283    '''.format(appid=appid, port=DEVICE_PORT,
284               metadata_size=payload.metadata_size,
285               payload_hash=sha256.hexdigest(),
286               payload_size=serving_size)
287    self.wfile.write(response_xml.strip())
288    return
289
290
291class ServerThread(threading.Thread):
292  """A thread for serving HTTP requests."""
293
294  def __init__(self, ota_filename, serving_range):
295    threading.Thread.__init__(self)
296    # serving_payload and serving_range are class attributes and the
297    # UpdateHandler class is instantiated with every request.
298    UpdateHandler.serving_payload = ota_filename
299    UpdateHandler.serving_range = serving_range
300    self._httpd = BaseHTTPServer.HTTPServer(('127.0.0.1', 0), UpdateHandler)
301    self.port = self._httpd.server_port
302
303  def run(self):
304    try:
305      self._httpd.serve_forever()
306    except (KeyboardInterrupt, socket.error):
307      pass
308    logging.info('Server Terminated')
309
310  def StopServer(self):
311    self._httpd.shutdown()
312    self._httpd.socket.close()
313
314
315def StartServer(ota_filename, serving_range):
316  t = ServerThread(ota_filename, serving_range)
317  t.start()
318  return t
319
320
321def AndroidUpdateCommand(ota_filename, secondary, payload_url, extra_headers):
322  """Return the command to run to start the update in the Android device."""
323  ota = AndroidOTAPackage(ota_filename, secondary)
324  headers = ota.properties
325  headers += b'USER_AGENT=Dalvik (something, something)\n'
326  headers += b'NETWORK_ID=0\n'
327  headers += extra_headers.encode()
328
329  return ['update_engine_client', '--update', '--follow',
330          '--payload=%s' % payload_url, '--offset=%d' % ota.offset,
331          '--size=%d' % ota.size, '--headers="%s"' % headers.decode()]
332
333
334def OmahaUpdateCommand(omaha_url):
335  """Return the command to run to start the update in a device using Omaha."""
336  return ['update_engine_client', '--update', '--follow',
337          '--omaha_url=%s' % omaha_url]
338
339
340class AdbHost(object):
341  """Represents a device connected via ADB."""
342
343  def __init__(self, device_serial=None):
344    """Construct an instance.
345
346    Args:
347        device_serial: options string serial number of attached device.
348    """
349    self._device_serial = device_serial
350    self._command_prefix = ['adb']
351    if self._device_serial:
352      self._command_prefix += ['-s', self._device_serial]
353
354  def adb(self, command, timeout_seconds: float = None):
355    """Run an ADB command like "adb push".
356
357    Args:
358      command: list of strings containing command and arguments to run
359
360    Returns:
361      the program's return code.
362
363    Raises:
364      subprocess.CalledProcessError on command exit != 0.
365    """
366    command = self._command_prefix + command
367    logging.info('Running: %s', ' '.join(str(x) for x in command))
368    p = subprocess.Popen(command, universal_newlines=True)
369    p.wait(timeout_seconds)
370    return p.returncode
371
372  def adb_output(self, command):
373    """Run an ADB command like "adb push" and return the output.
374
375    Args:
376      command: list of strings containing command and arguments to run
377
378    Returns:
379      the program's output as a string.
380
381    Raises:
382      subprocess.CalledProcessError on command exit != 0.
383    """
384    command = self._command_prefix + command
385    logging.info('Running: %s', ' '.join(str(x) for x in command))
386    return subprocess.check_output(command, universal_newlines=True)
387
388
389def PushMetadata(dut, otafile, metadata_path):
390  payload = update_payload.Payload(otafile)
391  payload.Init()
392  with tempfile.TemporaryDirectory() as tmpdir:
393    with zipfile.ZipFile(otafile, "r") as zfp:
394      extracted_path = os.path.join(tmpdir, "payload.bin")
395      with zfp.open("payload.bin") as payload_fp, \
396              open(extracted_path, "wb") as output_fp:
397          # Only extract the first |data_offset| bytes from the payload.
398          # This is because allocateSpaceForPayload only needs to see
399          # the manifest, not the entire payload.
400          # Extracting the entire payload works, but is slow for full
401          # OTA.
402        output_fp.write(payload_fp.read(payload.data_offset))
403
404      return dut.adb([
405          "push",
406          extracted_path,
407          metadata_path
408      ]) == 0
409
410
411def main():
412  parser = argparse.ArgumentParser(description='Android A/B OTA helper.')
413  parser.add_argument('otafile', metavar='PAYLOAD', type=str,
414                      help='the OTA package file (a .zip file) or raw payload \
415                      if device uses Omaha.')
416  parser.add_argument('--file', action='store_true',
417                      help='Push the file to the device before updating.')
418  parser.add_argument('--no-push', action='store_true',
419                      help='Skip the "push" command when using --file')
420  parser.add_argument('-s', type=str, default='', metavar='DEVICE',
421                      help='The specific device to use.')
422  parser.add_argument('--no-verbose', action='store_true',
423                      help='Less verbose output')
424  parser.add_argument('--public-key', type=str, default='',
425                      help='Override the public key used to verify payload.')
426  parser.add_argument('--extra-headers', type=str, default='',
427                      help='Extra headers to pass to the device.')
428  parser.add_argument('--secondary', action='store_true',
429                      help='Update with the secondary payload in the package.')
430  parser.add_argument('--no-slot-switch', action='store_true',
431                      help='Do not perform slot switch after the update.')
432  parser.add_argument('--no-postinstall', action='store_true',
433                      help='Do not execute postinstall scripts after the update.')
434  parser.add_argument('--allocate-only', action='store_true',
435                      help='Allocate space for this OTA, instead of actually \
436                        applying the OTA.')
437  parser.add_argument('--verify-only', action='store_true',
438                      help='Verify metadata then exit, instead of applying the OTA.')
439  parser.add_argument('--no-care-map', action='store_true',
440                      help='Do not push care_map.pb to device.')
441  parser.add_argument('--perform-slot-switch', action='store_true',
442                      help='Perform slot switch for this OTA package')
443  parser.add_argument('--perform-reset-slot-switch', action='store_true',
444                      help='Perform reset slot switch for this OTA package')
445  parser.add_argument('--wipe-user-data', action='store_true',
446                      help='Wipe userdata after installing OTA')
447  args = parser.parse_args()
448  logging.basicConfig(
449      level=logging.WARNING if args.no_verbose else logging.INFO)
450
451  start_time = time.perf_counter()
452
453  dut = AdbHost(args.s)
454
455  server_thread = None
456  # List of commands to execute on exit.
457  finalize_cmds = []
458  # Commands to execute when canceling an update.
459  cancel_cmd = ['shell', 'su', '0', 'update_engine_client', '--cancel']
460  # List of commands to perform the update.
461  cmds = []
462
463  help_cmd = ['shell', 'su', '0', 'update_engine_client', '--help']
464  use_omaha = 'omaha' in dut.adb_output(help_cmd)
465
466  metadata_path = "/data/ota_package/metadata"
467  if args.allocate_only:
468    if PushMetadata(dut, args.otafile, metadata_path):
469      dut.adb([
470          "shell", "update_engine_client", "--allocate",
471          "--metadata={}".format(metadata_path)])
472    # Return 0, as we are executing ADB commands here, no work needed after
473    # this point
474    return 0
475  if args.verify_only:
476    if PushMetadata(dut, args.otafile, metadata_path):
477      dut.adb([
478          "shell", "update_engine_client", "--verify",
479          "--metadata={}".format(metadata_path)])
480    # Return 0, as we are executing ADB commands here, no work needed after
481    # this point
482    return 0
483  if args.perform_slot_switch:
484    assert PushMetadata(dut, args.otafile, metadata_path)
485    dut.adb(["shell", "update_engine_client",
486            "--switch_slot=true", "--metadata={}".format(metadata_path), "--follow"])
487    return 0
488  if args.perform_reset_slot_switch:
489    assert PushMetadata(dut, args.otafile, metadata_path)
490    dut.adb(["shell", "update_engine_client",
491            "--switch_slot=false", "--metadata={}".format(metadata_path)])
492    return 0
493
494  if args.no_slot_switch:
495    args.extra_headers += "\nSWITCH_SLOT_ON_REBOOT=0"
496  if args.no_postinstall:
497    args.extra_headers += "\nRUN_POST_INSTALL=0"
498  if args.wipe_user_data:
499    args.extra_headers += "\nPOWERWASH=1"
500
501  with zipfile.ZipFile(args.otafile) as zfp:
502    CARE_MAP_ENTRY_NAME = "care_map.pb"
503    if CARE_MAP_ENTRY_NAME in zfp.namelist() and not args.no_care_map:
504      # Need root permission to push to /data
505      dut.adb(["root"])
506      with tempfile.NamedTemporaryFile() as care_map_fp:
507        care_map_fp.write(zfp.read(CARE_MAP_ENTRY_NAME))
508        care_map_fp.flush()
509        dut.adb(["push", care_map_fp.name,
510                "/data/ota_package/" + CARE_MAP_ENTRY_NAME])
511
512  if args.file:
513    # Update via pushing a file to /data.
514    device_ota_file = os.path.join(OTA_PACKAGE_PATH, 'debug.zip')
515    payload_url = 'file://' + device_ota_file
516    if not args.no_push:
517      data_local_tmp_file = '/data/local/tmp/debug.zip'
518      cmds.append(['push', args.otafile, data_local_tmp_file])
519      cmds.append(['shell', 'su', '0', 'mv', data_local_tmp_file,
520                   device_ota_file])
521      cmds.append(['shell', 'su', '0', 'chcon',
522                   'u:object_r:ota_package_file:s0', device_ota_file])
523    cmds.append(['shell', 'su', '0', 'chown', 'system:cache', device_ota_file])
524    cmds.append(['shell', 'su', '0', 'chmod', '0660', device_ota_file])
525  else:
526    # Update via sending the payload over the network with an "adb reverse"
527    # command.
528    payload_url = 'http://127.0.0.1:%d/payload' % DEVICE_PORT
529    if use_omaha and zipfile.is_zipfile(args.otafile):
530      ota = AndroidOTAPackage(args.otafile, args.secondary)
531      serving_range = (ota.offset, ota.size)
532    else:
533      serving_range = (0, os.stat(args.otafile).st_size)
534    server_thread = StartServer(args.otafile, serving_range)
535    cmds.append(
536        ['reverse', 'tcp:%d' % DEVICE_PORT, 'tcp:%d' % server_thread.port])
537    finalize_cmds.append(['reverse', '--remove', 'tcp:%d' % DEVICE_PORT])
538
539  if args.public_key:
540    payload_key_dir = os.path.dirname(PAYLOAD_KEY_PATH)
541    cmds.append(
542        ['shell', 'su', '0', 'mount', '-t', 'tmpfs', 'tmpfs', payload_key_dir])
543    # Allow adb push to payload_key_dir
544    cmds.append(['shell', 'su', '0', 'chcon', 'u:object_r:shell_data_file:s0',
545                 payload_key_dir])
546    cmds.append(['push', args.public_key, PAYLOAD_KEY_PATH])
547    # Allow update_engine to read it.
548    cmds.append(['shell', 'su', '0', 'chcon', '-R', 'u:object_r:system_file:s0',
549                 payload_key_dir])
550    finalize_cmds.append(['shell', 'su', '0', 'umount', payload_key_dir])
551
552  try:
553    # The main update command using the configured payload_url.
554    if use_omaha:
555      update_cmd = \
556          OmahaUpdateCommand('http://127.0.0.1:%d/update' % DEVICE_PORT)
557    else:
558      update_cmd = AndroidUpdateCommand(args.otafile, args.secondary,
559                                        payload_url, args.extra_headers)
560    cmds.append(['shell', 'su', '0'] + update_cmd)
561
562    for cmd in cmds:
563      dut.adb(cmd)
564  except KeyboardInterrupt:
565    dut.adb(cancel_cmd)
566  finally:
567    if server_thread:
568      server_thread.StopServer()
569    for cmd in finalize_cmds:
570      dut.adb(cmd, 5)
571
572  logging.info('Update took %.3f seconds', (time.perf_counter() - start_time))
573  return 0
574
575
576if __name__ == '__main__':
577  sys.exit(main())
578