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