From f24370d641cbbd8a4bee6e397678e4607632e836 Mon Sep 17 00:00:00 2001 From: Jon Olson Date: Sun, 4 Oct 2026 21:53:08 -0700 Subject: [PATCH 1/3] Bound Linux control transfers using the native USB ABI. The fixed ioctl request encoded a 64-bit structure on 32-bit hosts, while buffer lengths and long deadlines could wrap before reaching usbfs. Derive the request size from the native structure, reject oversized buffers, and round and clamp the timeout without overflow. Pin the data through the synchronous ioctl so its integer address cannot outlive the backing storage. --- docs/linux-usb.md | 7 +++ usb/control.go | 22 ++++++++- usb/control_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/docs/linux-usb.md b/docs/linux-usb.md index 9a193d2..edc6a59 100644 --- a/docs/linux-usb.md +++ b/docs/linux-usb.md @@ -5,6 +5,13 @@ commands should run as an unprivileged user. Do not work around device permissions with `sudo go run` or `sudo go test`: those commands compile and execute repository code as root. +Endpoint-zero control transfers accept buffers up to 65,535 bytes. The host +timeout defaults to five seconds; a context deadline rounds up to the next +millisecond and clamps to the Linux unsigned 32-bit millisecond limit. Control +requests use the native pointer width, and their buffers remain pinned until the +ioctl returns. This ABI handling does not establish physical validation on every +Linux architecture. + ## Grant device access Give the interactive user permission to open only the intended USB products. For diff --git a/usb/control.go b/usb/control.go index c856401..bfd0c45 100644 --- a/usb/control.go +++ b/usb/control.go @@ -5,11 +5,13 @@ package usb import ( "context" "errors" + "math" + "runtime" "time" "unsafe" ) -const usbfsControl = 0xc0185500 +const usbfsControl = uintptr(0xc0005500) | unsafe.Sizeof(usbControlTransfer{})<<16 const defaultTransferTimeout = 5 * time.Second @@ -24,7 +26,13 @@ type usbControlTransfer struct { } // ControlTransfer performs one deadline-bounded endpoint-zero transfer. +// Buffers may contain at most 65,535 bytes. Without a context deadline, the +// transfer timeout is five seconds. Positive deadlines round up to milliseconds +// and clamp to the host's 32-bit millisecond limit. func (d *Device) ControlTransfer(ctx context.Context, requestType, request uint8, value, index uint16, data []byte) (int, error) { + if len(data) > math.MaxUint16 { + return 0, errors.New("usb: control buffer exceeds USB limit") + } timeout, err := transferTimeout(ctx) if err != nil { return 0, err @@ -37,10 +45,14 @@ func (d *Device) ControlTransfer(ctx context.Context, requestType, request uint8 Length: uint16(len(data)), Timeout: timeout, } + var pinned runtime.Pinner if len(data) > 0 { + pinned.Pin(&data[0]) + defer pinned.Unpin() control.Data = uintptr(unsafe.Pointer(&data[0])) } count, err := d.runIOCTL(usbfsControl, &control) + runtime.KeepAlive(data) return int(count), err } @@ -58,6 +70,12 @@ func transferTimeout(ctx context.Context) (uint32, error) { return 0, context.DeadlineExceeded } } - milliseconds := (timeout + time.Millisecond - 1) / time.Millisecond + milliseconds := timeout / time.Millisecond + if timeout%time.Millisecond != 0 { + milliseconds++ + } + if milliseconds > math.MaxUint32 { + milliseconds = math.MaxUint32 + } return uint32(milliseconds), nil } diff --git a/usb/control_test.go b/usb/control_test.go index e60e28b..aff356c 100644 --- a/usb/control_test.go +++ b/usb/control_test.go @@ -6,7 +6,11 @@ import ( "context" "errors" "os" + "runtime" + "syscall" "testing" + "time" + "unsafe" ) func TestDevicePerformsBoundedControlTransfer(t *testing.T) { @@ -14,6 +18,7 @@ func TestDevicePerformsBoundedControlTransfer(t *testing.T) { if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = file.Close() }) device := &Device{file: file} var got *usbControlTransfer device.ioctl = func(_ uintptr, request uintptr, argument any) (uintptr, error) { @@ -40,6 +45,112 @@ func TestDevicePerformsBoundedControlTransfer(t *testing.T) { } } +func TestControlTransferNativeABI(t *testing.T) { + wantSize, wantOffset, wantRequest := uintptr(24), uintptr(16), uintptr(0xc0185500) + if unsafe.Sizeof(uintptr(0)) == 4 { + wantSize, wantOffset, wantRequest = 16, 12, 0xc0105500 + } + if unsafe.Sizeof(usbControlTransfer{}) != wantSize || unsafe.Offsetof(usbControlTransfer{}.Data) != wantOffset { + t.Fatal("control structure does not match the Linux USB ABI") + } + if uintptr(usbfsControl) != wantRequest { + t.Fatalf("control request = %#x, want %#x", uintptr(usbfsControl), wantRequest) + } +} + +func TestControlTransferBufferBounds(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "usb-device") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + device := &Device{file: file} + data := make([]byte, 65535) + called := 0 + device.ioctl = func(_ uintptr, _ uintptr, argument any) (uintptr, error) { + called++ + control := argument.(*usbControlTransfer) + runtime.GC() + if control.Length != 65535 || control.Data != uintptr(unsafe.Pointer(&data[0])) { + t.Fatal("control transfer did not retain the complete buffer") + } + data[len(data)-1] = 0xa5 + return uintptr(len(data)), nil + } + count, err := device.ControlTransfer(context.Background(), 0x80, 1, 0, 0, data) + if err != nil || count != len(data) || data[len(data)-1] != 0xa5 { + t.Fatalf("ControlTransfer() = %d, %v", count, err) + } + if _, err := device.ControlTransfer(context.Background(), 0x80, 1, 0, 0, make([]byte, 65536)); err == nil { + t.Fatal("oversized control buffer was accepted") + } + if called != 1 { + t.Fatalf("ioctl calls = %d, want 1", called) + } +} + +func TestControlTransferDistantDeadline(t *testing.T) { + for _, duration := range []time.Duration{50 * 24 * time.Hour, time.Duration(1<<63 - 1)} { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(duration)) + got, err := transferTimeout(ctx) + cancel() + if err != nil || got != 0xffffffff { + t.Fatalf("timeout for %v = %d, %v; want 4294967295", duration, got, err) + } + } +} + +func TestControlTransferPinsStackBuffer(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "control-input") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + if _, err := file.WriteAt([]byte{0xa5}, 0); err != nil { + t.Fatal(err) + } + device := &Device{file: file} + device.ioctl = func(fd, _ uintptr, argument any) (uintptr, error) { + control := argument.(*usbControlTransfer) + return controlReadAfterStackGrowth(64, fd, control.Data) + } + done := make(chan error, 1) + go func() { done <- controlTransferStackBuffer(device) }() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func controlTransferStackBuffer(device *Device) error { + var data [16]byte + count, err := device.ControlTransfer(context.Background(), 0x80, 1, 0, 0, data[:]) + if err != nil { + return err + } + if count != 1 || data[0] != 0xa5 { + return errors.New("control input did not reach the caller's buffer after stack growth") + } + return nil +} + +func controlReadAfterStackGrowth(depth int, fd, address uintptr) (uintptr, error) { + var padding [4096]byte + padding[0] = byte(depth) + var count uintptr + var err error + if depth > 0 { + count, err = controlReadAfterStackGrowth(depth-1, fd, address) + } else { + result, _, errno := syscall.Syscall(syscall.SYS_READ, fd, address, 1) + count = result + if errno != 0 { + err = errno + } + } + runtime.KeepAlive(padding) + return count, err +} + func TestControlTransferHonorsPreCanceledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() From 1fbcd104eeeb5d819edf2d839ac5548233075687 Mon Sep 17 00:00:00 2001 From: Jon Olson Date: Sun, 4 Oct 2026 21:56:43 -0700 Subject: [PATCH 2/3] Use host-specific Linux USB ioctl direction bits. PowerPC and MIPS encode ioctl directions differently from the generic Linux ABI. Native structure sizes alone leave interface and bulk requests using the wrong command numbers. Select the direction masks for the host and retain compile-time checks against the expected USB request numbers on both encoding families. --- docs/linux-usb.md | 5 +++-- usb/control.go | 2 +- usb/interface.go | 6 +++--- usb/ioctl_extended_linux.go | 9 +++++++++ usb/ioctl_extended_linux_test.go | 21 +++++++++++++++++++++ usb/ioctl_generic_linux.go | 9 +++++++++ usb/ioctl_generic_linux_test.go | 21 +++++++++++++++++++++ usb/transfer_linux.go | 6 +++--- 8 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 usb/ioctl_extended_linux.go create mode 100644 usb/ioctl_extended_linux_test.go create mode 100644 usb/ioctl_generic_linux.go create mode 100644 usb/ioctl_generic_linux_test.go diff --git a/docs/linux-usb.md b/docs/linux-usb.md index edc6a59..4e20588 100644 --- a/docs/linux-usb.md +++ b/docs/linux-usb.md @@ -9,8 +9,9 @@ Endpoint-zero control transfers accept buffers up to 65,535 bytes. The host timeout defaults to five seconds; a context deadline rounds up to the next millisecond and clamps to the Linux unsigned 32-bit millisecond limit. Control requests use the native pointer width, and their buffers remain pinned until the -ioctl returns. This ABI handling does not establish physical validation on every -Linux architecture. +ioctl returns. Interface and bulk requests use the ioctl direction encoding for +the host architecture, including the PowerPC and MIPS variants. This ABI +handling does not establish physical validation on every Linux architecture. ## Grant device access diff --git a/usb/control.go b/usb/control.go index bfd0c45..9505a9e 100644 --- a/usb/control.go +++ b/usb/control.go @@ -11,7 +11,7 @@ import ( "unsafe" ) -const usbfsControl = uintptr(0xc0005500) | unsafe.Sizeof(usbControlTransfer{})<<16 +const usbfsControl = usbfsIOCRead | usbfsIOCWrite | uintptr(0x5500) | unsafe.Sizeof(usbControlTransfer{})<<16 const defaultTransferTimeout = 5 * time.Second diff --git a/usb/interface.go b/usb/interface.go index 436aad9..0b70028 100644 --- a/usb/interface.go +++ b/usb/interface.go @@ -10,9 +10,9 @@ import ( ) const ( - usbfsClaimInterface = 0x8004550f - usbfsReleaseInterface = 0x80045510 - usbfsSetInterface = 0x80085504 + usbfsClaimInterface = usbfsIOCRead | uintptr(0x4550f) + usbfsReleaseInterface = usbfsIOCRead | uintptr(0x45510) + usbfsSetInterface = usbfsIOCRead | uintptr(0x85504) ) type ioctlFunc func(fd, request uintptr, argument any) (uintptr, error) diff --git a/usb/ioctl_extended_linux.go b/usb/ioctl_extended_linux.go new file mode 100644 index 0000000..6672f26 --- /dev/null +++ b/usb/ioctl_extended_linux.go @@ -0,0 +1,9 @@ +//go:build linux && (ppc64 || ppc64le || mips || mipsle || mips64 || mips64le) + +package usb + +const ( + usbfsIOCNone = uintptr(0x20000000) + usbfsIOCRead = uintptr(0x40000000) + usbfsIOCWrite = uintptr(0x80000000) +) diff --git a/usb/ioctl_extended_linux_test.go b/usb/ioctl_extended_linux_test.go new file mode 100644 index 0000000..4d6429e --- /dev/null +++ b/usb/ioctl_extended_linux_test.go @@ -0,0 +1,21 @@ +//go:build linux && (ppc64 || ppc64le || mips || mipsle || mips64 || mips64le) + +package usb + +import "unsafe" + +const ( + expectedControl = uintptr(0xc0105500) + (unsafe.Sizeof(uintptr(0))/8)*0x80000 + expectedSubmitURB = uintptr(0x402c550a) + (unsafe.Sizeof(uintptr(0))/8)*0xc0000 + expectedReapURB = uintptr(0x8000550d) + unsafe.Sizeof(uintptr(0))*0x10000 +) + +var ( + _ [0 - int(usbfsControl^expectedControl)]byte + _ [0 - int(usbfsClaimInterface^uintptr(0x4004550f))]byte + _ [0 - int(usbfsReleaseInterface^uintptr(0x40045510))]byte + _ [0 - int(usbfsSetInterface^uintptr(0x40085504))]byte + _ [0 - int(usbfsDiscardURB^uintptr(0x2000550b))]byte + _ [0 - int(usbfsSubmitURB^expectedSubmitURB)]byte + _ [0 - int(usbfsReapURBNoDelay^expectedReapURB)]byte +) diff --git a/usb/ioctl_generic_linux.go b/usb/ioctl_generic_linux.go new file mode 100644 index 0000000..6be9316 --- /dev/null +++ b/usb/ioctl_generic_linux.go @@ -0,0 +1,9 @@ +//go:build linux && !(ppc64 || ppc64le || mips || mipsle || mips64 || mips64le) + +package usb + +const ( + usbfsIOCNone = uintptr(0) + usbfsIOCRead = uintptr(0x80000000) + usbfsIOCWrite = uintptr(0x40000000) +) diff --git a/usb/ioctl_generic_linux_test.go b/usb/ioctl_generic_linux_test.go new file mode 100644 index 0000000..9c3a8dc --- /dev/null +++ b/usb/ioctl_generic_linux_test.go @@ -0,0 +1,21 @@ +//go:build linux && !(ppc64 || ppc64le || mips || mipsle || mips64 || mips64le) + +package usb + +import "unsafe" + +const ( + expectedControl = uintptr(0xc0105500) + (unsafe.Sizeof(uintptr(0))/8)*0x80000 + expectedSubmitURB = uintptr(0x802c550a) + (unsafe.Sizeof(uintptr(0))/8)*0xc0000 + expectedReapURB = uintptr(0x4000550d) + unsafe.Sizeof(uintptr(0))*0x10000 +) + +var ( + _ [0 - int(usbfsControl^expectedControl)]byte + _ [0 - int(usbfsClaimInterface^uintptr(0x8004550f))]byte + _ [0 - int(usbfsReleaseInterface^uintptr(0x80045510))]byte + _ [0 - int(usbfsSetInterface^uintptr(0x80085504))]byte + _ [0 - int(usbfsDiscardURB^uintptr(0x550b))]byte + _ [0 - int(usbfsSubmitURB^expectedSubmitURB)]byte + _ [0 - int(usbfsReapURBNoDelay^expectedReapURB)]byte +) diff --git a/usb/transfer_linux.go b/usb/transfer_linux.go index 66e1909..864f5eb 100644 --- a/usb/transfer_linux.go +++ b/usb/transfer_linux.go @@ -16,12 +16,12 @@ import ( const ( usbfsURBBulk = 3 - usbfsDiscardURB = uintptr(0x550b) + usbfsDiscardURB = usbfsIOCNone | uintptr(0x550b) ) const ( - usbfsSubmitURB = uintptr(0x8000550a) | unsafe.Sizeof(usbURB{})<<16 - usbfsReapURBNoDelay = uintptr(0x4000550d) | unsafe.Sizeof(uintptr(0))<<16 + usbfsSubmitURB = usbfsIOCRead | uintptr(0x550a) | unsafe.Sizeof(usbURB{})<<16 + usbfsReapURBNoDelay = usbfsIOCWrite | uintptr(0x550d) | unsafe.Sizeof(uintptr(0))<<16 ) type usbURB struct { From 946a2a8f6e2f818ce64e90e09317d9d67a4ccecd Mon Sep 17 00:00:00 2001 From: Jon Olson Date: Sat, 31 Oct 2026 13:06:10 -0700 Subject: [PATCH 3/3] =?UTF-8?q?Update=20the=20policy=20checker=E2=80=99s?= =?UTF-8?q?=20HTML=20parser.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML parser used on pull-request descriptions is affected by GO-2026-6860. Upgrade x/net to the fixed release and require Go 1.26.10 so the checker also avoids the affected Go 1.26.0 URL parser. Record the new minimum in the installation instructions. --- README.md | 2 +- go.mod | 4 ++-- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e83a9f3..ade84af 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ People and coding agents changing Ostiole itself should follow the shared ## Requirements -Ostiole requires Go 1.25.12 or newer. On macOS 12 or newer, install Xcode or the +Ostiole requires Go 1.26.10 or newer. On macOS 12 or newer, install Xcode or the Xcode command-line tools. The native USB implementation uses cgo to call the system IOKit and CoreFoundation frameworks; it does not require libusb or a third-party USB package. diff --git a/go.mod b/go.mod index 80a613d..45cd5af 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/jon/ostiole -go 1.25.12 +go 1.26.10 require ( github.com/yuin/goldmark v1.8.6 - golang.org/x/net v0.58.0 + golang.org/x/net v0.62.0 ) diff --git a/go.sum b/go.sum index 08bf7ad..9f7ff36 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=