mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
feat(ios): native usbmux client and in-process tunnel forwarder
Talk to macOS usbmuxd directly instead of shelling out to iproxy, so the device path depends on nothing beyond macOS + Xcode.
This commit is contained in:
1 parent
9776e9f5b7
commit
956b114cba
2 files changed
+650
No files matched your search
@@ -0,0 +1,422 @@
|
|||||||
|
// This file implements the host-to-device TCP forward the device runner needs,
|
||||||
|
// natively, against macOS's own usbmuxd. The runner exposes a JSON-RPC server on
|
||||||
|
// the device loopback; the host dials it through this forward. usbmuxd is the
|
||||||
|
// macOS daemon at /var/run/usbmuxd that already multiplexes every USB device
|
||||||
|
// connection; a third-party client (iproxy and the like) is only a thin speaker
|
||||||
|
// of the same protocol, so talking to the socket directly keeps the device path
|
||||||
|
// dependent on nothing beyond the OS.
|
||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usbmuxdSocket is the macOS usbmuxd unix socket. It is part of the base OS, not
|
||||||
|
// an installed dependency.
|
||||||
|
const usbmuxdSocket = "/var/run/usbmuxd"
|
||||||
|
|
||||||
|
// usbmux message framing: a 16-byte little-endian header (length including the
|
||||||
|
// header, protocol version, payload type, request tag) precedes an XML plist.
|
||||||
|
const (
|
||||||
|
usbmuxHeaderLength = 16
|
||||||
|
usbmuxVersion = 1
|
||||||
|
usbmuxPayloadPlist = 8
|
||||||
|
usbmuxTag = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// usbmuxDial opens a live byte pipe to devicePort on the device identified by
|
||||||
|
// hardwareUDID: it resolves the device's usbmux id, then issues a Connect whose
|
||||||
|
// success turns the usbmuxd socket into a raw conduit to that device port. The
|
||||||
|
// returned net.Conn is the device side of the runner's TCP server.
|
||||||
|
func usbmuxDial(ctx context.Context, hardwareUDID string, devicePort int) (net.Conn, error) {
|
||||||
|
deviceID, err := usbmuxDeviceID(ctx, hardwareUDID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := dialUsbmuxd(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// usbmux carries the port in network byte order.
|
||||||
|
request, err := encodePlistDict(map[string]any{
|
||||||
|
"MessageType": "Connect",
|
||||||
|
"DeviceID": deviceID,
|
||||||
|
"PortNumber": int(htons(uint16(devicePort))),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeUsbmuxMessage(conn, request); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reply, err := readUsbmuxMessage(conn)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := parsePlistDict(reply)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if number, _ := result["Number"].(int); number != 0 {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("usbmux: connect to device port %d failed (result %d)", devicePort, number)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usbmuxDeviceID lists attached devices and returns the usbmux id of the one
|
||||||
|
// whose serial matches hardwareUDID. usbmux ids are assigned per attachment and
|
||||||
|
// can change across reconnects, so it is resolved fresh on every dial.
|
||||||
|
func usbmuxDeviceID(ctx context.Context, hardwareUDID string) (int, error) {
|
||||||
|
conn, err := dialUsbmuxd(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
request, err := encodePlistDict(map[string]any{"MessageType": "ListDevices"})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := writeUsbmuxMessage(conn, request); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
reply, err := readUsbmuxMessage(conn)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
list, err := parsePlistDict(reply)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return selectDeviceID(list, hardwareUDID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectDeviceID picks the usbmux DeviceID for hardwareUDID from a parsed
|
||||||
|
// ListDevices reply, matching the serial with dashes and case ignored so the
|
||||||
|
// devicectl HardwareUDID (dashed) and the raw USB serial (undashed) both resolve.
|
||||||
|
func selectDeviceID(list map[string]any, hardwareUDID string) (int, error) {
|
||||||
|
devices, _ := list["DeviceList"].([]any)
|
||||||
|
target := normalizeSerial(hardwareUDID)
|
||||||
|
for _, entry := range devices {
|
||||||
|
device, ok := entry.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
properties, _ := device["Properties"].(map[string]any)
|
||||||
|
serial, _ := properties["SerialNumber"].(string)
|
||||||
|
if normalizeSerial(serial) != target {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, ok := device["DeviceID"].(int); ok {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
if id, ok := properties["DeviceID"].(int); ok {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("usbmux: device %s not attached", hardwareUDID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSerial(serial string) string {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(serial, "-", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// htons swaps a port to network byte order, as the usbmux Connect PortNumber
|
||||||
|
// requires.
|
||||||
|
func htons(port uint16) uint16 {
|
||||||
|
return port<<8 | port>>8
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialUsbmuxd(ctx context.Context) (net.Conn, error) {
|
||||||
|
dialer := net.Dialer{}
|
||||||
|
conn, err := dialer.DialContext(ctx, "unix", usbmuxdSocket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux: dial %s: %w", usbmuxdSocket, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUsbmuxMessage(conn net.Conn, payload []byte) error {
|
||||||
|
header := make([]byte, usbmuxHeaderLength)
|
||||||
|
binary.LittleEndian.PutUint32(header[0:4], uint32(usbmuxHeaderLength+len(payload)))
|
||||||
|
binary.LittleEndian.PutUint32(header[4:8], usbmuxVersion)
|
||||||
|
binary.LittleEndian.PutUint32(header[8:12], usbmuxPayloadPlist)
|
||||||
|
binary.LittleEndian.PutUint32(header[12:16], usbmuxTag)
|
||||||
|
if _, err := conn.Write(header); err != nil {
|
||||||
|
return fmt.Errorf("usbmux: write header: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(payload); err != nil {
|
||||||
|
return fmt.Errorf("usbmux: write payload: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUsbmuxMessage(conn net.Conn) ([]byte, error) {
|
||||||
|
header := make([]byte, usbmuxHeaderLength)
|
||||||
|
if _, err := io.ReadFull(conn, header); err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux: read header: %w", err)
|
||||||
|
}
|
||||||
|
length := binary.LittleEndian.Uint32(header[0:4])
|
||||||
|
if length < usbmuxHeaderLength {
|
||||||
|
return nil, fmt.Errorf("usbmux: reply length %d shorter than header", length)
|
||||||
|
}
|
||||||
|
payload := make([]byte, length-usbmuxHeaderLength)
|
||||||
|
if _, err := io.ReadFull(conn, payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux: read payload: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodePlistDict renders a flat dict (string or int values) as the XML plist
|
||||||
|
// usbmux requests use. Keys are sorted so the output is deterministic.
|
||||||
|
func encodePlistDict(fields map[string]any) ([]byte, error) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
buffer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||||
|
buffer.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
|
||||||
|
buffer.WriteString(`<plist version="1.0">` + "\n<dict>\n")
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for key := range fields {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
fmt.Fprintf(&buffer, "<key>%s</key>", key)
|
||||||
|
switch value := fields[key].(type) {
|
||||||
|
case string:
|
||||||
|
buffer.WriteString("<string>")
|
||||||
|
xml.EscapeText(&buffer, []byte(value))
|
||||||
|
buffer.WriteString("</string>\n")
|
||||||
|
case int:
|
||||||
|
fmt.Fprintf(&buffer, "<integer>%d</integer>\n", value)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("usbmux: unsupported plist value type %T for key %s", value, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.WriteString("</dict>\n</plist>\n")
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePlistDict parses an XML plist whose root is a dict into a generic map.
|
||||||
|
func parsePlistDict(data []byte) (map[string]any, error) {
|
||||||
|
value, err := parsePlist(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dict, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("usbmux: plist root is %T, want dict", value)
|
||||||
|
}
|
||||||
|
return dict, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePlist decodes an XML plist into nested map[string]any / []any / string /
|
||||||
|
// int / bool values. It covers the element set usbmux replies use.
|
||||||
|
func parsePlist(data []byte) (any, error) {
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux: parse plist: %w", err)
|
||||||
|
}
|
||||||
|
if start, ok := token.(xml.StartElement); ok && start.Name.Local == "plist" {
|
||||||
|
return parsePlistChild(decoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePlistChild reads forward to the next start element and parses it as a
|
||||||
|
// value. It is used for the lone child of <plist> and of each <key>.
|
||||||
|
func parsePlistChild(decoder *xml.Decoder) (any, error) {
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch element := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
return parsePlistElement(decoder, element)
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePlistElement(decoder *xml.Decoder, start xml.StartElement) (any, error) {
|
||||||
|
switch start.Name.Local {
|
||||||
|
case "dict":
|
||||||
|
return parsePlistDictBody(decoder)
|
||||||
|
case "array":
|
||||||
|
return parsePlistArray(decoder)
|
||||||
|
case "string":
|
||||||
|
return parsePlistText(decoder)
|
||||||
|
case "integer":
|
||||||
|
text, err := parsePlistText(decoder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(strings.TrimSpace(text))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux: parse integer %q: %w", text, err)
|
||||||
|
}
|
||||||
|
return number, nil
|
||||||
|
case "true":
|
||||||
|
return true, decoder.Skip()
|
||||||
|
case "false":
|
||||||
|
return false, decoder.Skip()
|
||||||
|
default:
|
||||||
|
// Unhandled scalar (real, data, date): consume it and report nil so an
|
||||||
|
// unexpected field never aborts parsing the fields that matter.
|
||||||
|
return nil, decoder.Skip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePlistDictBody(decoder *xml.Decoder) (map[string]any, error) {
|
||||||
|
result := map[string]any{}
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch element := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if element.Name.Local != "key" {
|
||||||
|
return nil, fmt.Errorf("usbmux: expected <key>, got <%s>", element.Name.Local)
|
||||||
|
}
|
||||||
|
key, err := parsePlistText(decoder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, err := parsePlistChild(decoder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[key] = value
|
||||||
|
case xml.EndElement:
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePlistArray(decoder *xml.Decoder) ([]any, error) {
|
||||||
|
var result []any
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch element := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
value, err := parsePlistElement(decoder, element)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, value)
|
||||||
|
case xml.EndElement:
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePlistText(decoder *xml.Decoder) (string, error) {
|
||||||
|
var text strings.Builder
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch element := token.(type) {
|
||||||
|
case xml.CharData:
|
||||||
|
text.Write(element)
|
||||||
|
case xml.EndElement:
|
||||||
|
return text.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usbmuxForwarder is the in-process replacement for an iproxy child: it accepts
|
||||||
|
// host loopback connections and bridges each to a fresh device-port conduit over
|
||||||
|
// usbmux. It satisfies io.Closer so the driver tears it down like any other
|
||||||
|
// tunnel handle; closing the listener ends the accept loop and the bridges drain
|
||||||
|
// as their copies finish.
|
||||||
|
type usbmuxForwarder struct {
|
||||||
|
listener net.Listener
|
||||||
|
dialDevice func(ctx context.Context) (net.Conn, error)
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUsbmuxForwarder listens on localAddress and forwards every accepted
|
||||||
|
// connection to devicePort on the device, over usbmux.
|
||||||
|
func startUsbmuxForwarder(ctx context.Context, hardwareUDID, localAddress string, devicePort int) (*usbmuxForwarder, error) {
|
||||||
|
return startForwarder(ctx, localAddress, func(dialCtx context.Context) (net.Conn, error) {
|
||||||
|
return usbmuxDial(dialCtx, hardwareUDID, devicePort)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startForwarder is the seam-friendly core: the device dialer is injected so a
|
||||||
|
// test can bridge to an in-process echo server without a real device.
|
||||||
|
func startForwarder(ctx context.Context, localAddress string, dialDevice func(context.Context) (net.Conn, error)) (*usbmuxForwarder, error) {
|
||||||
|
listener, err := net.Listen("tcp", localAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux forwarder: listen %s: %w", localAddress, err)
|
||||||
|
}
|
||||||
|
forwarder := &usbmuxForwarder{listener: listener, dialDevice: dialDevice, ctx: ctx}
|
||||||
|
go forwarder.serve()
|
||||||
|
return forwarder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *usbmuxForwarder) serve() {
|
||||||
|
for {
|
||||||
|
hostConn, err := f.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go f.bridge(hostConn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bridge connects to the device side, then copies bytes in both directions
|
||||||
|
// until either end closes. A failed device dial (the runner not yet listening)
|
||||||
|
// closes the host side, which the runner transport reads as a dropped
|
||||||
|
// connection and recovers from on its next call.
|
||||||
|
func (f *usbmuxForwarder) bridge(hostConn net.Conn) {
|
||||||
|
defer hostConn.Close()
|
||||||
|
deviceConn, err := f.dialDevice(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer deviceConn.Close()
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() { io.Copy(deviceConn, hostConn); done <- struct{}{} }()
|
||||||
|
go func() { io.Copy(hostConn, deviceConn); done <- struct{}{} }()
|
||||||
|
// One direction ending closes both conns via the defers, which unblocks the
|
||||||
|
// other copy; waiting for one is enough to know the bridge is finished.
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *usbmuxForwarder) Close() error {
|
||||||
|
return f.listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUsbmuxTunnel adapts the forwarder to the driver's startTunnel seam,
|
||||||
|
// returning it as an io.Closer.
|
||||||
|
func startUsbmuxTunnel(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error) {
|
||||||
|
port, err := strconv.Atoi(devicePort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("usbmux tunnel: device port %q: %w", devicePort, err)
|
||||||
|
}
|
||||||
|
return startUsbmuxForwarder(ctx, hardwareUDID, localAddress, port)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package ioscompanion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHtonsSwapsBytes(t *testing.T) {
|
||||||
|
// 49200 = 0xC030; network order swaps to 0x30C0 = 12480.
|
||||||
|
if got := htons(49200); got != 12480 {
|
||||||
|
t.Fatalf("htons(49200) = %d, want 12480", got)
|
||||||
|
}
|
||||||
|
if got := htons(0x1234); got != 0x3412 {
|
||||||
|
t.Fatalf("htons(0x1234) = %#x, want 0x3412", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodePlistDictRoundTrips(t *testing.T) {
|
||||||
|
encoded, err := encodePlistDict(map[string]any{
|
||||||
|
"MessageType": "Connect",
|
||||||
|
"DeviceID": 7,
|
||||||
|
"PortNumber": 12480,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dict, err := parsePlistDict(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse round-trip: %v", err)
|
||||||
|
}
|
||||||
|
if dict["MessageType"] != "Connect" {
|
||||||
|
t.Fatalf("MessageType = %v, want Connect", dict["MessageType"])
|
||||||
|
}
|
||||||
|
if dict["DeviceID"] != 7 {
|
||||||
|
t.Fatalf("DeviceID = %v, want 7", dict["DeviceID"])
|
||||||
|
}
|
||||||
|
if dict["PortNumber"] != 12480 {
|
||||||
|
t.Fatalf("PortNumber = %v, want 12480", dict["PortNumber"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodePlistDictEscapesStrings(t *testing.T) {
|
||||||
|
encoded, err := encodePlistDict(map[string]any{"Name": "a & b <c>"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dict, err := parsePlistDict(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dict["Name"] != "a & b <c>" {
|
||||||
|
t.Fatalf("Name = %q, want the unescaped original", dict["Name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDevicesReply is a representative usbmux ListDevices response with one USB
|
||||||
|
// device, matching the shape macOS usbmuxd returns.
|
||||||
|
const listDevicesReply = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>DeviceList</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>DeviceID</key>
|
||||||
|
<integer>7</integer>
|
||||||
|
<key>MessageType</key>
|
||||||
|
<string>Attached</string>
|
||||||
|
<key>Properties</key>
|
||||||
|
<dict>
|
||||||
|
<key>ConnectionType</key>
|
||||||
|
<string>USB</string>
|
||||||
|
<key>SerialNumber</key>
|
||||||
|
<string>00008140-00022C4A3E13001C</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>`
|
||||||
|
|
||||||
|
func TestSelectDeviceIDMatchesDashedSerial(t *testing.T) {
|
||||||
|
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id, err := selectDeviceID(list, "00008140-00022C4A3E13001C")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select by dashed serial: %v", err)
|
||||||
|
}
|
||||||
|
if id != 7 {
|
||||||
|
t.Fatalf("DeviceID = %d, want 7", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDeviceIDMatchesUndashedSerial(t *testing.T) {
|
||||||
|
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The raw USB serial carries no dash; it must still resolve.
|
||||||
|
id, err := selectDeviceID(list, "0000814000022C4A3E13001C")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select by undashed serial: %v", err)
|
||||||
|
}
|
||||||
|
if id != 7 {
|
||||||
|
t.Fatalf("DeviceID = %d, want 7", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDeviceIDNotAttached(t *testing.T) {
|
||||||
|
list, err := parsePlistDict([]byte(listDevicesReply))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := selectDeviceID(list, "DEADBEEF-NOTHERE"); err == nil {
|
||||||
|
t.Fatal("a serial not in the list must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePlistDictRejectsNonDictRoot(t *testing.T) {
|
||||||
|
arrayRoot := `<?xml version="1.0"?><plist version="1.0"><array></array></plist>`
|
||||||
|
if _, err := parsePlistDict([]byte(arrayRoot)); err == nil {
|
||||||
|
t.Fatal("a non-dict plist root must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwarderBridgesToDevice drives the forwarder end to end against an
|
||||||
|
// in-process echo "device": a host connection through the loopback listener must
|
||||||
|
// reach the injected device dialer and round-trip bytes.
|
||||||
|
func TestForwarderBridgesToDevice(t *testing.T) {
|
||||||
|
deviceListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deviceListener.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, acceptErr := deviceListener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go io.Copy(conn, conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
localAddress, err := pickLoopbackAddress()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||||
|
return net.Dial("tcp", deviceListener.Addr().String())
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer forwarder.Close()
|
||||||
|
|
||||||
|
hostConn, err := net.Dial("tcp", localAddress)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer hostConn.Close()
|
||||||
|
|
||||||
|
message := []byte("ping\n")
|
||||||
|
if _, err := hostConn.Write(message); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buffer := make([]byte, len(message))
|
||||||
|
if _, err := io.ReadFull(hostConn, buffer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(buffer) != string(message) {
|
||||||
|
t.Fatalf("round-trip = %q, want %q", buffer, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwarderClosesHostOnDeviceDialFailure asserts the failure path the runner
|
||||||
|
// transport relies on: when the device dial fails (the runner not yet
|
||||||
|
// listening), the forwarder closes the host side so the caller sees a dropped
|
||||||
|
// connection and retries.
|
||||||
|
func TestForwarderClosesHostOnDeviceDialFailure(t *testing.T) {
|
||||||
|
localAddress, err := pickLoopbackAddress()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer forwarder.Close()
|
||||||
|
|
||||||
|
hostConn, err := net.Dial("tcp", localAddress)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer hostConn.Close()
|
||||||
|
|
||||||
|
// The forwarder closes its side after the failed device dial; the read
|
||||||
|
// returns EOF rather than blocking forever.
|
||||||
|
if _, err := io.ReadFull(hostConn, make([]byte, 1)); err == nil {
|
||||||
|
t.Fatal("read must fail once the forwarder closes the host side")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwarderCloseStopsAcceptLoop(t *testing.T) {
|
||||||
|
localAddress, err := pickLoopbackAddress()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
|
||||||
|
return nil, io.EOF
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := forwarder.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// After Close the listener is gone, so a dial must fail.
|
||||||
|
if conn, dialErr := net.Dial("tcp", localAddress); dialErr == nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatal("dial must fail after the forwarder is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user