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