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