//go:build linux package bridge import ( "encoding/json" "net" "os" "path/filepath" "testing" "time" "github.com/duffy/usb-server/internal/usb" "golang.org/x/sys/unix" ) // A minimal but valid descriptor blob: device descriptor, one configuration, // one HID interface, one interrupt IN endpoint. func testDescriptors() []byte { dev := []byte{ 18, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 64, 0x6d, 0x04, // idVendor 046d 0x1c, 0xc0, // idProduct c01c 0x00, 0x01, // bcdDevice 1, 2, 3, 1, } iface := []byte{9, 0x04, 0, 0, 1, 0x03, 0x01, 0x02, 0} ep := []byte{7, 0x05, 0x81, 0x03, 8, 0, 10} body := append(iface, ep...) cfg := append([]byte{9, 0x02, byte(9 + len(body)), 0, 1, 1, 0, 0x80, 250}, body...) return append(dev, cfg...) } // send delivers one request, attaching fd if it is non-negative. func send(t *testing.T, conn *net.UnixConn, req Request, fd int) Response { t.Helper() data, err := json.Marshal(req) if err != nil { t.Fatalf("marshalling request: %v", err) } var oob []byte if fd >= 0 { oob = unix.UnixRights(fd) } if _, _, err := conn.WriteMsgUnix(data, oob, nil); err != nil { t.Fatalf("sending request: %v", err) } conn.SetReadDeadline(time.Now().Add(3 * time.Second)) buf := make([]byte, 4096) n, err := conn.Read(buf) if err != nil { t.Fatalf("reading response: %v", err) } var resp Response if err := json.Unmarshal(buf[:n], &resp); err != nil { t.Fatalf("parsing response %q: %v", buf[:n], err) } return resp } func startServer(t *testing.T) (*Server, *net.UnixConn) { t.Helper() path := filepath.Join(t.TempDir(), "bridge.sock") srv, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) } t.Cleanup(func() { srv.Close() }) conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: path, Net: "unix"}) if err != nil { t.Fatalf("dialling bridge: %v", err) } t.Cleanup(func() { conn.Close() }) return srv, conn } // openTestFD returns a real descriptor to hand over. Its contents do not // matter — nothing in the bridge reads from it — only that it is open. func openTestFD(t *testing.T) int { t.Helper() f, err := os.CreateTemp(t.TempDir(), "fd") if err != nil { t.Fatalf("creating temp file: %v", err) } defer f.Close() fd, err := unix.Dup(int(f.Fd())) if err != nil { t.Fatalf("dup: %v", err) } return fd } func TestAddRegistersDeviceAndDescriptor(t *testing.T) { usb.UnregisterExternalDevice("9-9") srv, conn := startServer(t) changed := make(chan struct{}, 1) srv.OnChange = func() { select { case changed <- struct{}{}: default: } } resp := send(t, conn, Request{ Action: "add", BusID: "9-9", Descriptors: testDescriptors(), BusNum: 9, DevNum: 9, Speed: 3, ConfigValue: 1, Manufacturer: "Test", Product: "Keyboard", }, openTestFD(t)) if !resp.OK { t.Fatalf("add failed: %s", resp.Error) } t.Cleanup(func() { usb.UnregisterExternalDevice("9-9") }) select { case <-changed: case <-time.After(2 * time.Second): t.Error("OnChange did not fire after a device was added") } // The device must show up in enumeration, parsed from the blob. var found *usb.Device for _, d := range usb.ExternalDevices() { if d.BusID == "9-9" { cp := d found = &cp } } if found == nil { t.Fatal("device was not registered") } if found.VendorID != 0x046d || found.ProductID != 0xc01c { t.Errorf("got %04x:%04x, want 046d:c01c", found.VendorID, found.ProductID) } if found.Product != "Keyboard" { t.Errorf("product = %q, want %q", found.Product, "Keyboard") } // The endpoint has to survive with its real transfer type, which is the // whole reason the descriptors are sent along. ep, ok := found.Endpoints[0x81] if !ok { t.Fatal("endpoint 0x81 missing from the parsed descriptors") } if ep.TransferType != usb.TransferTypeInterrupt { t.Errorf("endpoint type = %d, want interrupt", ep.TransferType) } if !usb.HasAdoptedFD("9-9") { t.Error("the file descriptor was not adopted") } } func TestAddRejectsMissingPieces(t *testing.T) { _, conn := startServer(t) tests := []struct { name string req Request fd bool }{ {"no bus id", Request{Action: "add", Descriptors: testDescriptors()}, true}, {"no descriptors", Request{Action: "add", BusID: "8-8"}, true}, {"no file descriptor", Request{Action: "add", BusID: "8-8", Descriptors: testDescriptors()}, false}, {"garbage descriptors", Request{Action: "add", BusID: "8-8", Descriptors: []byte{1, 2, 3}}, true}, {"unknown action", Request{Action: "frobnicate", BusID: "8-8"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fd := -1 if tt.fd { fd = openTestFD(t) } resp := send(t, conn, tt.req, fd) if resp.OK { t.Error("request was accepted but should have been rejected") } if resp.Error == "" { t.Error("rejection carried no explanation") } }) } if usb.HasAdoptedFD("8-8") { t.Error("a rejected request left a descriptor behind") usb.ReleaseAdoptedFDs() } } func TestRemoveWithdrawsDevice(t *testing.T) { _, conn := startServer(t) resp := send(t, conn, Request{ Action: "add", BusID: "7-7", Descriptors: testDescriptors(), ConfigValue: 1, }, openTestFD(t)) if !resp.OK { t.Fatalf("add failed: %s", resp.Error) } resp = send(t, conn, Request{Action: "remove", BusID: "7-7"}, -1) if !resp.OK { t.Fatalf("remove failed: %s", resp.Error) } for _, d := range usb.ExternalDevices() { if d.BusID == "7-7" { t.Fatal("device is still registered after removal") } } } // The socket lets its holder make this client share arbitrary devices, so it // must not be world-writable. func TestSocketIsPrivate(t *testing.T) { path := filepath.Join(t.TempDir(), "bridge.sock") srv, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) } defer srv.Close() info, err := os.Stat(path) if err != nil { t.Fatalf("stat: %v", err) } if perm := info.Mode().Perm(); perm != 0600 { t.Errorf("socket permissions are %04o, want 0600", perm) } } // Restarting must not fail because the previous socket file is still there. func TestListenReplacesStaleSocket(t *testing.T) { path := filepath.Join(t.TempDir(), "bridge.sock") first, err := Listen(path) if err != nil { t.Fatalf("first Listen: %v", err) } first.listener.Close() // simulate a crash: socket file survives second, err := Listen(path) if err != nil { t.Fatalf("second Listen failed on a leftover socket: %v", err) } second.Close() } func TestCloseRemovesSocket(t *testing.T) { path := filepath.Join(t.TempDir(), "bridge.sock") srv, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) } srv.Close() if _, err := os.Stat(path); !os.IsNotExist(err) { t.Error("the socket file outlived the server") } }