1/**
2 * Copyright (c) 2021 Expo, Inc.
3 * Copyright (c) 2018 Drifty Co.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8import Debug from 'debug';
9import { Socket } from 'net';
10
11import { CommandError } from '../../../../utils/errors';
12import {
13  ProtocolClient,
14  ProtocolReader,
15  ProtocolReaderCallback,
16  ProtocolReaderFactory,
17  ProtocolWriter,
18} from './AbstractProtocol';
19
20const debug = Debug('expo:apple-device:protocol:gdb');
21const ACK_SUCCESS = '+'.charCodeAt(0);
22
23export interface GDBMessage {
24  cmd: string;
25  args: string[];
26}
27
28export class GDBProtocolClient extends ProtocolClient<GDBMessage> {
29  constructor(socket: Socket) {
30    super(socket, new ProtocolReaderFactory(GDBProtocolReader), new GDBProtocolWriter());
31  }
32}
33
34export class GDBProtocolReader extends ProtocolReader {
35  constructor(callback: ProtocolReaderCallback) {
36    super(1 /* "Header" is '+' or '-' */, callback);
37  }
38
39  onData(data?: Buffer) {
40    // the GDB protocol does not support body length in its header so we cannot rely on
41    // the parent implementation to determine when a payload is complete
42    try {
43      // if there's data, add it to the existing buffer
44      this.buffer = data ? Buffer.concat([this.buffer, data]) : this.buffer;
45
46      // do we have enough bytes to proceed
47      if (this.buffer.length < this.headerSize) {
48        return; // incomplete header, wait for more
49      }
50
51      // first, check the header
52      if (this.parseHeader(this.buffer) === -1) {
53        // we have a valid header so check the body. GDB packets will always be a leading '$', data bytes,
54        // a trailing '#', and a two digit checksum. minimum valid body is the empty response '$#00'
55        // https://developer.apple.com/library/archive/documentation/DeveloperTools/gdb/gdb/gdb_33.html
56        const packetData = this.buffer.toString().match('\\$.*#[0-9a-f]{2}');
57        if (packetData == null) {
58          return; // incomplete body, wait for more
59        }
60        // extract the body and update the buffer
61        const body = Buffer.from(packetData[0]);
62        this.buffer = this.buffer.slice(this.headerSize + body.length);
63        // parse the payload and recurse if there is more data to process
64        this.callback(this.parseBody(body));
65        if (this.buffer.length) {
66          this.onData();
67        }
68      }
69    } catch (err: any) {
70      this.callback(null, err);
71    }
72  }
73
74  parseHeader(data: Buffer) {
75    if (data[0] !== ACK_SUCCESS) {
76      throw new CommandError('APPLE_DEVICE_GDB', 'Unsuccessful debugserver response');
77    } // TODO: retry?
78    return -1;
79  }
80
81  parseBody(buffer: Buffer) {
82    debug(`Response body: ${buffer.toString()}`);
83    // check for checksum
84    const checksum = buffer.slice(-3).toString();
85    if (checksum.match(/#[0-9a-f]{2}/)) {
86      // remove '$' prefix and checksum
87      const msg = buffer.slice(1, -3).toString();
88      if (validateChecksum(checksum, msg)) {
89        return msg;
90      } else if (msg.startsWith('E')) {
91        if (msg.match(/the device was not, or could not be, unlocked/)) {
92          throw new CommandError('APPLE_DEVICE_LOCKED', 'Device is currently locked.');
93        }
94
95        // Error message from debugserver -- Drop the `E`
96        return msg.slice(1);
97      } else {
98        throw new CommandError(
99          'APPLE_DEVICE_GDB',
100          `Invalid checksum received from debugserver. (checksum: ${checksum}, msg: ${msg})`
101        );
102      }
103    } else {
104      throw new CommandError('APPLE_DEVICE_GDB', "Didn't receive checksum");
105    }
106  }
107}
108
109export class GDBProtocolWriter implements ProtocolWriter {
110  write(socket: Socket, msg: GDBMessage) {
111    const { cmd, args } = msg;
112    debug(`Socket write: ${cmd}, args: ${args}`);
113    // hex encode and concat all args
114    const encodedArgs = args
115      .map((arg) => Buffer.from(arg).toString('hex'))
116      .join()
117      .toUpperCase();
118    const checksumStr = calculateChecksum(cmd + encodedArgs);
119    const formattedCmd = `$${cmd}${encodedArgs}#${checksumStr}`;
120    socket.write(formattedCmd);
121  }
122}
123
124// hex value of (sum of cmd chars mod 256)
125function calculateChecksum(cmdStr: string) {
126  let checksum = 0;
127  for (let i = 0; i < cmdStr.length; i++) {
128    checksum += cmdStr.charCodeAt(i);
129  }
130  let result = (checksum % 256).toString(16);
131  // pad if necessary
132  if (result.length === 1) {
133    result = `0${result}`;
134  }
135  return result;
136}
137
138export function validateChecksum(checksum: string, msg: string) {
139  // remove '#' from checksum
140  const checksumVal = checksum.startsWith('#') ? checksum.slice(1) : checksum;
141  // remove '$' from msg and calculate its checksum
142  const computedChecksum = calculateChecksum(msg);
143  // debug(`Checksum: ${checksumVal}, computed checksum: ${computedChecksum}`);
144  return checksumVal === computedChecksum;
145}
146