Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs/linux-usb.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ 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. 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

Give the interactive user permission to open only the intended USB products. For
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jon marked this conversation as resolved.
Resolved
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
22 changes: 20 additions & 2 deletions usb/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ package usb
import (
"context"
"errors"
"math"
"runtime"
"time"
"unsafe"
)

const usbfsControl = 0xc0185500
const usbfsControl = usbfsIOCRead | usbfsIOCWrite | uintptr(0x5500) | unsafe.Sizeof(usbControlTransfer{})<<16

const defaultTransferTimeout = 5 * time.Second

Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
}
111 changes: 111 additions & 0 deletions usb/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ import (
"context"
"errors"
"os"
"runtime"
"syscall"
"testing"
"time"
"unsafe"
)

func TestDevicePerformsBoundedControlTransfer(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}
var got *usbControlTransfer
device.ioctl = func(_ uintptr, request uintptr, argument any) (uintptr, error) {
Expand All @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions usb/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions usb/ioctl_extended_linux.go
Original file line number Diff line number Diff line change
@@ -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)
)
21 changes: 21 additions & 0 deletions usb/ioctl_extended_linux_test.go
Original file line number Diff line number Diff line change
@@ -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
)
9 changes: 9 additions & 0 deletions usb/ioctl_generic_linux.go
Original file line number Diff line number Diff line change
@@ -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)
)
21 changes: 21 additions & 0 deletions usb/ioctl_generic_linux_test.go
Original file line number Diff line number Diff line change
@@ -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
)
6 changes: 3 additions & 3 deletions usb/transfer_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading