From ceb8bd80465a6ab03d7175f9da005f7d8a6ffaca Mon Sep 17 00:00:00 2001 From: deadprogram Date: Wed, 29 Jul 2026 19:54:36 +0200 Subject: [PATCH 1/5] ci: use go.mod file as minimum Go version for macOS tests Signed-off-by: deadprogram --- .github/workflows/macos.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 7ac2c16..8908485 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -16,12 +16,12 @@ jobs: name: macos-14 runs-on: macos-14 steps: + - name: Checkout + uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v6 with: - go-version: '1.23.12' - - name: Checkout - uses: actions/checkout@v6 + go-version-file: 'go.mod' - name: Run unit tests run: go test - name: "Run macOS smoke tests" From 6a982445c2fd3f266b5e6cd76c22eb6c68bb65b8 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Mon, 27 Jul 2026 13:25:03 +0200 Subject: [PATCH 2/5] espradio: add ESP32 BLE support using espradio VHCI transport Adds a new `espradio` build tag that wires the existing HCI stack up to the ESP32's virtual HCI controller via tinygo.org/x/espradio. The new Adapter calls espradio.BLEInit() and hands a VHCI-backed transport to newBLEStack(); for WiFi+BLE co-existence, espradio.Enable() should be called first. The shared HCI implementation files pick up the espradio build tag, and go.mod is updated for the espradio and drivers dependencies. Signed-off-by: deadprogram --- Makefile | 4 +++ adapter_espradio.go | 71 ++++++++++++++++++++++++++++++++++++++++ adapter_hci.go | 2 +- att_hci.go | 2 +- examples/scanner/main.go | 5 +++ gap_hci.go | 2 +- gattc_hci.go | 2 +- gatts_hci.go | 2 +- go.mod | 15 +++++---- go.sum | 28 ++++++++++------ hci.go | 2 +- l2cap_hci.go | 2 +- uuid_hci.go | 2 +- 13 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 adapter_espradio.go diff --git a/Makefile b/Makefile index c6fa21b..3342fde 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,10 @@ smoketest-tinygo: @md5sum test.hex $(TINYGO) build -o test.uf2 -size=short -target=badger2040-w ./examples/advertisement @md5sum test.hex + $(TINYGO) build -o test.bin -size=short -target=xiao-esp32c3 ./examples/advertisement + @md5sum test.bin + $(TINYGO) build -o test.bin -size=short -target=xiao-esp32c3 ./examples/discover + @md5sum test.bin smoketest-linux: # Test on Linux. diff --git a/adapter_espradio.go b/adapter_espradio.go new file mode 100644 index 0000000..ef962c3 --- /dev/null +++ b/adapter_espradio.go @@ -0,0 +1,71 @@ +//go:build espradio + +package bluetooth + +import ( + "runtime" + + "tinygo.org/x/espradio" +) + +const maxConnections = 1 + +// Adapter represents the BLE adapter on the ESP32 via espradio VHCI transport. +type Adapter struct { + hciAdapter +} + +// DefaultAdapter is the default adapter on the current system. +// +// Make sure to call Enable() before using it to initialize the adapter. +var DefaultAdapter = &Adapter{ + hciAdapter: hciAdapter{ + isDefault: true, + connectHandler: func(device Device, connected bool) { + return + }, + connectedDevices: make([]Device, 0, maxConnections), + }, +} + +// Enable configures the BLE stack. It must be called before any +// Bluetooth-related calls (unless otherwise indicated). +// For WiFi+BLE co-existence, call espradio.Enable() first. +func (a *Adapter) Enable() error { + if err := espradio.BLEInit(); err != nil { + return err + } + + transport := &hciVHCI{} + + a.hci, a.att = newBLEStack(transport) + + a.enable() + + return nil +} + +// hciVHCI wraps espradio's BLE VHCI transport to implement the +// unexported hciTransport interface. +type hciVHCI struct { + t espradio.VHCITransport +} + +func (h *hciVHCI) startRead() { runtime.Gosched() } +func (h *hciVHCI) endRead() {} + +func (h *hciVHCI) Buffered() int { + return h.t.Buffered() +} + +func (h *hciVHCI) ReadByte() (byte, error) { + return h.t.ReadByte() +} + +func (h *hciVHCI) Read(buf []byte) (int, error) { + return h.t.Read(buf) +} + +func (h *hciVHCI) Write(buf []byte) (int, error) { + return h.t.Write(buf) +} diff --git a/adapter_hci.go b/adapter_hci.go index f36bc09..08b6c07 100644 --- a/adapter_hci.go +++ b/adapter_hci.go @@ -1,4 +1,4 @@ -//go:build hci || ninafw || cyw43439 +//go:build hci || ninafw || cyw43439 || espradio package bluetooth diff --git a/att_hci.go b/att_hci.go index 93b86d7..018becc 100644 --- a/att_hci.go +++ b/att_hci.go @@ -1,4 +1,4 @@ -//go:build hci || ninafw || cyw43439 +//go:build hci || ninafw || cyw43439 || espradio package bluetooth diff --git a/examples/scanner/main.go b/examples/scanner/main.go index 11ef2ad..d95d0b2 100644 --- a/examples/scanner/main.go +++ b/examples/scanner/main.go @@ -1,12 +1,17 @@ package main import ( + "time" + "tinygo.org/x/bluetooth" ) var adapter = bluetooth.DefaultAdapter func main() { + time.Sleep(time.Second) // wait for the system to initialize + println("Starting BLE") + // Enable BLE interface. must("enable BLE stack", adapter.Enable()) diff --git a/gap_hci.go b/gap_hci.go index c674b90..e7b8e2c 100644 --- a/gap_hci.go +++ b/gap_hci.go @@ -1,4 +1,4 @@ -//go:build hci || ninafw || cyw43439 +//go:build hci || ninafw || cyw43439 || espradio package bluetooth diff --git a/gattc_hci.go b/gattc_hci.go index 28df2c8..987f707 100644 --- a/gattc_hci.go +++ b/gattc_hci.go @@ -1,4 +1,4 @@ -//go:build hci || ninafw || cyw43439 +//go:build hci || ninafw || cyw43439 || espradio package bluetooth diff --git a/gatts_hci.go b/gatts_hci.go index 9f9dbb3..20cc01e 100644 --- a/gatts_hci.go +++ b/gatts_hci.go @@ -1,4 +1,4 @@ -//go:build hci || ninafw || cyw43439 +//go:build hci || ninafw || cyw43439 || espradio package bluetooth diff --git a/go.mod b/go.mod index 9563607..eaf9516 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,17 @@ module tinygo.org/x/bluetooth -go 1.23.8 +go 1.25.0 require ( github.com/go-ole/go-ole v1.2.6 github.com/godbus/dbus/v5 v5.1.0 github.com/saltosystems/winrt-go v0.0.0-20260317170058-9c2fec580d96 - github.com/soypat/cyw43439 v0.1.0 + github.com/soypat/cyw43439 v0.1.2-0.20260731160358-f2a6af121857 github.com/tinygo-org/cbgo v0.0.4 golang.org/x/crypto v0.12.0 - tinygo.org/x/drivers v0.35.0 + golang.org/x/sys v0.11.0 + tinygo.org/x/drivers v0.35.1-0.20260604174950-1d695a231aef + tinygo.org/x/espradio v0.2.0 tinygo.org/x/tinyfont v0.6.0 tinygo.org/x/tinyterm v0.5.0 ) @@ -17,10 +19,9 @@ require ( require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/soypat/lneto v0.1.0 // indirect - github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect + github.com/soypat/lneto v0.3.2 // indirect + github.com/soypat/seqs v0.0.0-20260125140838-2c1c6b1bd69e // indirect github.com/tinygo-org/pio v0.3.0 // indirect - golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect - golang.org/x/sys v0.11.0 // indirect + golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect golang.org/x/term v0.11.0 // indirect ) diff --git a/go.sum b/go.sum index 18da4c5..ad65387 100644 --- a/go.sum +++ b/go.sum @@ -15,12 +15,14 @@ github.com/saltosystems/winrt-go v0.0.0-20260317170058-9c2fec580d96/go.mod h1:CI github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/soypat/cyw43439 v0.1.0 h1:3Nyqg2LSndhCYgCr2VXuL2nn73vyaJXAnD02veMoLvA= -github.com/soypat/cyw43439 v0.1.0/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc= -github.com/soypat/lneto v0.1.0 h1:VAHCJ33hvC3wDqhM0Vm7w0k6vwNsOCAsQ8XTrXJpS7I= -github.com/soypat/lneto v0.1.0/go.mod h1:g/8Lk+hIsMZydyWDJjK2YfsCuG6jA5mWCO6U+4S7w1U= -github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA= -github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= +github.com/soypat/cyw43439 v0.1.2-0.20260731160358-f2a6af121857 h1:FupkkbuNKByxNhVcFMOu7ZT3v4b+et0sE4ZzC66hIl0= +github.com/soypat/cyw43439 v0.1.2-0.20260731160358-f2a6af121857/go.mod h1:hStbAH1nOOWlo1ltrPd6V1GoIQYoW5/L6HcKZRlVp04= +github.com/soypat/lneto v0.3.1 h1:LaEsfTDpRlYjNbk6qV2jYMSNFs0gG79M6GrzpRY3EH8= +github.com/soypat/lneto v0.3.1/go.mod h1:Be5PjwoYukvHFiUXxpYi8+ppH2F/gw/vjGBvFdv+Ti8= +github.com/soypat/lneto v0.3.2 h1:iUFeRSq2czT7Db6MMOsAnMCBlKCqvIr941zsNf9dcu0= +github.com/soypat/lneto v0.3.2/go.mod h1:Be5PjwoYukvHFiUXxpYi8+ppH2F/gw/vjGBvFdv+Ti8= +github.com/soypat/seqs v0.0.0-20260125140838-2c1c6b1bd69e h1:xF3R+8683ngGNUeIy8PHJZiJZ/XIw+hlGgxg572P0Mw= +github.com/soypat/seqs v0.0.0-20260125140838-2c1c6b1bd69e/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -32,8 +34,8 @@ github.com/tinygo-org/pio v0.3.0 h1:opEnOtw58KGB4RJD3/n/Rd0/djYGX3DeJiXLI6y/yDI= github.com/tinygo-org/pio v0.3.0/go.mod h1:wf6c6lKZp+pQOzKKcpzchmRuhiMc27ABRuo7KVnaMFU= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -45,8 +47,14 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -tinygo.org/x/drivers v0.35.0 h1:cTK36tsI/S4Mg3hCPH0MBjV/ta7XKQ+wpvch4mVqgsE= -tinygo.org/x/drivers v0.35.0/go.mod h1:DQgKyHkB4G6IEOKVTAjApbKnWGwESN91EVJO+nMOE9Y= +tinygo.org/x/drivers v0.35.1-0.20260604174950-1d695a231aef h1:nG/qd6hSQonHse2l8DYUrCuJSiCfcFD9n8YMze8sVo0= +tinygo.org/x/drivers v0.35.1-0.20260604174950-1d695a231aef/go.mod h1:DQgKyHkB4G6IEOKVTAjApbKnWGwESN91EVJO+nMOE9Y= +tinygo.org/x/espradio v0.1.1-0.20260803204842-51f7cb095db5 h1:IibXiZ717j99GobRSUbqoc/FySzxZ0lu59Q9ohquyd8= +tinygo.org/x/espradio v0.1.1-0.20260803204842-51f7cb095db5/go.mod h1:1l1bd9M8w8iXQ5eFfOlyPpZT+0l+DutBLxSYIHPj2mI= +tinygo.org/x/espradio v0.1.1-0.20260803232258-42ec574d1fce h1:TdXH4wtWRbUMdFBc3VlOvVZbHCWKR3qUqQB1TcDwkCo= +tinygo.org/x/espradio v0.1.1-0.20260803232258-42ec574d1fce/go.mod h1:bib3tci08oBCaSE/V6BzpKiymkjMmhChCL8OR3sbDGM= +tinygo.org/x/espradio v0.2.0 h1:eD7CcEIjNWBz3BxRmQ9y8cFYhGdSvY0u8y/bHuooCSo= +tinygo.org/x/espradio v0.2.0/go.mod h1:bib3tci08oBCaSE/V6BzpKiymkjMmhChCL8OR3sbDGM= tinygo.org/x/tinyfont v0.6.0 h1:GibXDSFz6xrWnEDkDRo6vsbOyRw0MVj/eza3zNHMSHs= tinygo.org/x/tinyfont v0.6.0/go.mod h1:onflMSkpWl7r7j4MIqhPEVV39pn7yL4N3MOePl3G+G8= tinygo.org/x/tinyterm v0.5.0 h1:HusDSiTM290KzYM4+2X+ZfNM6Ll1yu13LpvIszlQE9M= diff --git a/hci.go b/hci.go index 08ad838..000d92d 100644 --- a/hci.go +++ b/hci.go @@ -1,4 +1,4 @@ -//go:build ninafw || hci || cyw43439 +//go:build ninafw || hci || cyw43439 || espradio package bluetooth diff --git a/l2cap_hci.go b/l2cap_hci.go index c4267ff..cdd90cf 100644 --- a/l2cap_hci.go +++ b/l2cap_hci.go @@ -1,4 +1,4 @@ -//go:build ninafw || hci || cyw43439 +//go:build ninafw || hci || cyw43439 || espradio package bluetooth diff --git a/uuid_hci.go b/uuid_hci.go index 2028706..7d52968 100644 --- a/uuid_hci.go +++ b/uuid_hci.go @@ -1,4 +1,4 @@ -//go:build hci || ninafw || cyw43439 +//go:build hci || ninafw || cyw43439 || espradio package bluetooth From cbfd03f459306da3eae9256d9dfde9f5c2227bb9 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Mon, 3 Aug 2026 12:33:02 -0700 Subject: [PATCH 3/5] hci: bounds check data received over the air to fix scanning panics Advertisement payload parsing and the HCI receive path both used wire-supplied lengths to index fixed-size buffers without validating them, panicking with "slice out of range" while scanning on ESP32. Add nextADField as a single bounds-checked AD field iterator, clamp the read size in poll after the 4-byte rounding, reject packets larger than the read buffer, and validate lengths before indexing in the event and ACL handlers. Add tests for malformed advertisement payloads. Signed-off-by: deadprogram --- gap.go | 90 +++++++++++++++++++--------- gap_test.go | 92 +++++++++++++++++++++++++++++ hci.go | 167 ++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 294 insertions(+), 55 deletions(-) diff --git a/gap.go b/gap.go index 31565a7..02ce9c3 100644 --- a/gap.go +++ b/gap.go @@ -276,24 +276,42 @@ func (buf *rawAdvertisementPayload) Bytes() []byte { return buf.data[:buf.len] } -// findField returns the data of a specific field in the advertisement packet. +// nextADField splits the first AD field off the front of an advertisement +// payload. It returns the field type, the field data (excluding the length and +// type bytes) and the remainder of the payload. It returns ok=false once there +// are no more usable fields, which includes malformed input: advertisement data +// comes straight off the air, so a field length that runs past the end of the +// buffer must not be trusted. // // See this list of field types: // https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ +func nextADField(data []byte) (fieldType byte, field, rest []byte, ok bool) { + if len(data) < 2 { + return 0, nil, nil, false + } + fieldLength := int(data[0]) + if fieldLength < 1 || fieldLength+1 > len(data) { + // A zero length marks the end of the significant part of the payload, + // and a length beyond the end of the buffer means it is malformed. + // Either way, stop here. + return 0, nil, nil, false + } + return data[1], data[2 : fieldLength+1], data[fieldLength+1:], true +} + +// findField returns the data of a specific field in the advertisement packet. func (buf *rawAdvertisementPayload) findField(fieldType byte) []byte { data := buf.Bytes() - for len(data) >= 2 { - fieldLength := data[0] - if int(fieldLength)+1 > len(data) { - // Invalid field length. + for { + typ, field, rest, ok := nextADField(data) + if !ok { return nil } - if fieldType == data[1] { - return data[2 : fieldLength+1] + if typ == fieldType { + return field } - data = data[fieldLength+1:] + data = rest } - return nil } // LocalName returns the local name (complete or shortened) in the advertisement @@ -376,19 +394,24 @@ func (buf *rawAdvertisementPayload) ServiceUUIDs() []UUID { // ManufacturerData returns the manufacturer data in the advertisement payload. func (buf *rawAdvertisementPayload) ManufacturerData() []ManufacturerDataElement { var manufacturerData []ManufacturerDataElement - for index := 0; index < int(buf.len); index += int(buf.data[index]) + 1 { - fieldLength := int(buf.data[index+0]) - if fieldLength < 3 { - continue + data := buf.Bytes() + for { + fieldType, field, rest, ok := nextADField(data) + if !ok { + break } - fieldType := buf.data[index+1] + data = rest + if fieldType != 0xff { continue } - key := uint16(buf.data[index+2]) | uint16(buf.data[index+3])<<8 + if len(field) < 2 { // no room for the company ID + continue + } + key := uint16(field[0]) | uint16(field[1])<<8 manufacturerData = append(manufacturerData, ManufacturerDataElement{ CompanyID: key, - Data: buf.data[index+4 : index+fieldLength+1], + Data: field[2:], }) } return manufacturerData @@ -397,32 +420,41 @@ func (buf *rawAdvertisementPayload) ManufacturerData() []ManufacturerDataElement // ServiceData returns the service data in the advertisment payload func (buf *rawAdvertisementPayload) ServiceData() []ServiceDataElement { var serviceData []ServiceDataElement - for index := 0; index < int(buf.len); index += int(buf.data[index]) + 1 { - fieldLength := int(buf.data[index+0]) - if fieldLength < 3 { // field has only length and type and no data - continue + data := buf.Bytes() + for { + fieldType, field, rest, ok := nextADField(data) + if !ok { + break } - fieldType := buf.data[index+1] + data = rest + switch fieldType { case 0x16: // 16-bit uuid + if len(field) < 2 { + continue + } serviceData = append(serviceData, ServiceDataElement{ - UUID: New16BitUUID(uint16(buf.data[index+2]) + (uint16(buf.data[index+3]) << 8)), - Data: buf.data[index+4 : index+fieldLength+1], + UUID: New16BitUUID(uint16(field[0]) + (uint16(field[1]) << 8)), + Data: field[2:], }) case 0x20: // 32-bit uuid + if len(field) < 4 { + continue + } serviceData = append(serviceData, ServiceDataElement{ - UUID: New32BitUUID(uint32(buf.data[index+2]) + (uint32(buf.data[index+3]) << 8) + (uint32(buf.data[index+4]) << 16) + (uint32(buf.data[index+5]) << 24)), - Data: buf.data[index+6 : index+fieldLength+1], + UUID: New32BitUUID(uint32(field[0]) + (uint32(field[1]) << 8) + (uint32(field[2]) << 16) + (uint32(field[3]) << 24)), + Data: field[4:], }) case 0x21: // 128-bit uuid + if len(field) < 16 { + continue + } var uuidArray [16]byte - copy(uuidArray[:], buf.data[index+2:index+18]) + copy(uuidArray[:], field[:16]) serviceData = append(serviceData, ServiceDataElement{ UUID: NewUUID(uuidArray), - Data: buf.data[index+18 : index+fieldLength+1], + Data: field[16:], }) - default: - continue } } return serviceData diff --git a/gap_test.go b/gap_test.go index de6ffed..2ff6b95 100644 --- a/gap_test.go +++ b/gap_test.go @@ -193,3 +193,95 @@ func TestServiceUUIDs(t *testing.T) { } } } + +// Advertisement payloads arrive straight off the air and may be truncated or +// otherwise malformed. Parsing one must never panic, no matter what it claims. +func TestMalformedAdvertisementPayload(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "manufacturer data length past end of payload", + raw: "\x02\x01\x06" + "\x0a\xff\x4c\x00\x01", + }, + { + name: "manufacturer data field claiming the maximum length", + raw: "\x02\x01\x06" + "\xff\xff\x4c\x00", + }, + { + name: "16-bit service data length past end of payload", + raw: "\x02\x01\x06" + "\x0a\x16\xd2\xfc\x40", + }, + { + name: "32-bit service data truncated inside the UUID", + raw: "\x02\x01\x06" + "\x04\x20\xd2\xfc", + }, + { + name: "128-bit service data truncated inside the UUID", + raw: "\x05\x21\xb8\x6c\x75\x05", + }, + { + name: "zero length field followed by a local name type byte", + raw: "\x00\x09foobar", + }, + { + name: "trailing zero padding after a valid field", + raw: "\x07\x09foobar" + "\x00\x00\x00", + }, + { + name: "truncated local name", + raw: "\x02\x01\x06" + "\x0c\x09foo", + }, + { + name: "single dangling length byte", + raw: "\x02\x01\x06" + "\x05", + }, + } + + check := func(t *testing.T, raw string) { + t.Helper() + var buf rawAdvertisementPayload + buf.len = uint8(copy(buf.data[:], raw)) + + // None of these may panic. The returned values are not checked: the + // input is garbage, so any output is acceptable as long as parsing + // stays inside the buffer. + buf.LocalName() + buf.ManufacturerData() + buf.ServiceData() + buf.ServiceUUIDs() + buf.HasServiceUUID(ServiceUUIDHeartRate) + buf.HasServiceUUID(ServiceUUIDAdafruitSound) + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + check(t, tc.raw) + }) + } + + // Exhaustive sweep: place a field of every possible declared length at + // every offset of a full-size payload, for each type this code parses. + t.Run("every field length at every offset", func(t *testing.T) { + for _, fieldType := range []byte{0x08, 0x09, 0x02, 0x03, 0x06, 0x07, 0x16, 0x20, 0x21, 0xff} { + for offset := 0; offset < 31; offset++ { + for fieldLength := 0; fieldLength <= 255; fieldLength++ { + for _, payloadLen := range []int{offset + 2, 31} { + if payloadLen > 31 { + continue + } + raw := make([]byte, payloadLen) + if offset < len(raw) { + raw[offset] = byte(fieldLength) + } + if offset+1 < len(raw) { + raw[offset+1] = fieldType + } + check(t, string(raw)) + } + } + } + } + }) +} diff --git a/hci.go b/hci.go index 000d92d..44807cb 100644 --- a/hci.go +++ b/hci.go @@ -151,10 +151,18 @@ type hci struct { pendingPkt uint16 } +// hciReadBufSize is the size of the packet read buffer. It must be large +// enough for the largest packet that can legitimately be received, otherwise +// such a packet can never be assembled: an HCI event carries a single byte +// parameter length, so the largest one is the packet type byte, the event code, +// the length byte and 255 parameter bytes. This also covers the largest ACL +// packet that can arrive given maximumMTU. +const hciReadBufSize = hciEvtLenPos + 255 + 1 + func newHCI(t hciTransport) *hci { return &hci{ transport: t, - buf: make([]byte, 256), + buf: make([]byte, hciReadBufSize), writebuf: make([]byte, 256), } } @@ -202,8 +210,15 @@ func (h *hci) poll() error { available = len(h.buf) - h.end } - // read in 4 byte aligned chunks + // Read in 4 byte aligned chunks. Rounding up may push the read + // past the end of the buffer, so clamp it back down: transports + // read up to the length of the slice, so a short read is fine but + // an out of bounds slice is not. aligned := available + (4-(available%4))%4 + if h.end+aligned > len(h.buf) { + aligned = len(h.buf) - h.end + } + n, err := h.transport.Read(h.buf[h.end : h.end+aligned]) if err != nil { return err @@ -263,16 +278,25 @@ func (h *hci) processPacket() (bool, error) { case hciACLDataPkt: if h.pos > hciACLLenPos { pktlen := int(binary.LittleEndian.Uint16(h.buf[3:5])) + + // Total size of the packet, including the leading packet type + // byte. The length comes off the wire, so it may be larger than + // the read buffer can ever hold. + pktTotal := hciACLLenPos + pktlen + 1 + if pktTotal > len(h.buf) { + return true, ErrHCIInvalidPacket + } + switch { - case h.end < hciACLLenPos+pktlen: + case h.end < pktTotal: // need to read more data return false, nil - case h.pos >= hciACLLenPos+pktlen: + case h.pos >= pktTotal: if debug { - println("hci acl data recv:", h.pos, hex.EncodeToString(h.buf[:hciACLLenPos+pktlen+1])) + println("hci acl data recv:", h.pos, hex.EncodeToString(h.buf[:pktTotal])) } - h.pos = hciACLLenPos + pktlen + 1 + h.pos = pktTotal return true, h.handleACLData(h.buf[1:h.pos]) } } @@ -281,16 +305,21 @@ func (h *hci) processPacket() (bool, error) { if h.pos > hciEvtLenPos { pktlen := int(h.buf[hciEvtLenPos]) + pktTotal := hciEvtLenPos + pktlen + 1 + if pktTotal > len(h.buf) { + return true, ErrHCIInvalidPacket + } + switch { - case h.end < hciEvtLenPos+pktlen: + case h.end < pktTotal: // need to read more data return false, nil - case h.pos >= hciEvtLenPos+pktlen: + case h.pos >= pktTotal: if debug { - println("hci event data recv:", h.pos, hex.EncodeToString(h.buf[:hciEvtLenPos+pktlen+1])) + println("hci event data recv:", h.pos, hex.EncodeToString(h.buf[:pktTotal])) } - h.pos = hciEvtLenPos + pktlen + 1 + h.pos = pktTotal return true, h.handleEventData(h.buf[1:h.pos]) } } @@ -324,6 +353,10 @@ func (h *hci) readBdAddr() error { return err } + if len(h.cmdResponse) < 7 { + return ErrHCIInvalidPacket + } + copy(h.address.MAC[:], h.cmdResponse[:7]) return nil @@ -567,6 +600,11 @@ type aclDataHeader struct { } func (h *hci) handleACLData(buf []byte) error { + // The ACL header is 4 bytes, followed by a 4 byte L2CAP header. + if len(buf) < 8 { + return ErrHCIInvalidPacket + } + aclHdr := aclDataHeader{ handle: binary.LittleEndian.Uint16(buf[0:]), dlen: binary.LittleEndian.Uint16(buf[2:]), @@ -575,10 +613,21 @@ func (h *hci) handleACLData(buf []byte) error { } aclFlags := (aclHdr.handle & 0xf000) >> 12 - if aclHdr.dlen-4 != aclHdr.len { + if aclHdr.dlen < 4 || aclHdr.dlen-4 != aclHdr.len { return errors.New("fragmented packet") } + // The L2CAP length comes off the wire, so check the payload is really + // present rather than trusting it. Computed as an int, since the uint16 + // arithmetic would wrap around. + end := 8 + int(aclHdr.len) + if end > len(buf) { + if debug { + println("invalid acl payload length", aclHdr.len, len(buf)) + } + return ErrHCIInvalidPacket + } + switch aclHdr.cid { case attCID: if aclFlags == 0x01 { @@ -586,16 +635,16 @@ func (h *hci) handleACLData(buf []byte) error { if debug { println("WARNING: att.handleACLData needs buffered packet") } - return h.att.handleData(aclHdr.handle&0x0fff, buf[8:aclHdr.len+8]) + return h.att.handleData(aclHdr.handle&0x0fff, buf[8:end]) } else { - return h.att.handleData(aclHdr.handle&0x0fff, buf[8:aclHdr.len+8]) + return h.att.handleData(aclHdr.handle&0x0fff, buf[8:end]) } case signalingCID: if debug { println("signaling cid", aclHdr.cid, hex.EncodeToString(buf)) } - return h.l2cap.handleData(aclHdr.handle&0x0fff, buf[8:aclHdr.len+8]) + return h.l2cap.handleData(aclHdr.handle&0x0fff, buf[8:end]) default: if debug { @@ -607,6 +656,11 @@ func (h *hci) handleACLData(buf []byte) error { } func (h *hci) handleEventData(buf []byte) error { + // Every event has at least an event code and a parameter length byte. + if len(buf) < 2 { + return ErrHCIInvalidPacket + } + evt := buf[0] plen := buf[1] @@ -616,6 +670,10 @@ func (h *hci) handleEventData(buf []byte) error { println("evtDisconnComplete") } + if len(buf) < 5 { + return ErrHCIInvalidPacket + } + handle := binary.LittleEndian.Uint16(buf[3:]) h.att.removeConnection(handle) h.l2cap.removeConnection(handle) @@ -631,6 +689,10 @@ func (h *hci) handleEventData(buf []byte) error { } case evtCmdComplete: + if len(buf) < 6 || int(plen)+2 > len(buf) { + return ErrHCIInvalidPacket + } + h.cmdCompleteOpcode = binary.LittleEndian.Uint16(buf[3:]) h.cmdCompleteStatus = buf[5] if plen > 0 { @@ -646,6 +708,10 @@ func (h *hci) handleEventData(buf []byte) error { return nil case evtCmdStatus: + if len(buf) < 6 { + return ErrHCIInvalidPacket + } + h.cmdCompleteStatus = buf[2] h.cmdCompleteOpcode = binary.LittleEndian.Uint16(buf[4:]) if debug { @@ -660,12 +726,22 @@ func (h *hci) handleEventData(buf []byte) error { if debug { println("evtNumCompPkts", hex.EncodeToString(buf)) } + if len(buf) < 3 { + return ErrHCIInvalidPacket + } + // count of handles c := buf[2] pkts := uint16(0) + // The handle count comes off the wire, so make sure the event is + // actually long enough to hold that many entries. + if 5+(int(c)-1)*4+2 > len(buf) { + return ErrHCIInvalidPacket + } + for i := byte(0); i < c; i++ { - pkts += binary.LittleEndian.Uint16(buf[5+i*4:]) + pkts += binary.LittleEndian.Uint16(buf[5+int(i)*4:]) } if pkts > 0 && h.pendingPkt > pkts { @@ -685,6 +761,11 @@ func (h *hci) handleEventData(buf []byte) error { println("evtLEMetaEvent") } + // An LE meta event has at least a subevent code. + if len(buf) < 3 { + return ErrHCIInvalidPacket + } + switch buf[2] { case leMetaEventConnComplete, leMetaEventEnhancedConnectionComplete: if debug { @@ -695,12 +776,25 @@ func (h *hci) handleEventData(buf []byte) error { } } + // The enhanced variant carries two extra addresses before the + // connection interval, so it needs a longer event. + minLen := 20 + if buf[2] == leMetaEventEnhancedConnectionComplete { + minLen = 32 + } + if len(buf) < minLen { + if debug { + println("invalid connection complete length", len(buf)) + } + return ErrHCIInvalidPacket + } + h.connectData.connected = true h.connectData.status = buf[3] h.connectData.handle = binary.LittleEndian.Uint16(buf[4:]) h.connectData.role = buf[6] h.connectData.peerBdaddrType = buf[7] - copy(h.connectData.peerBdaddr[0:], buf[8:]) + copy(h.connectData.peerBdaddr[0:], buf[8:14]) switch buf[2] { case leMetaEventConnComplete: @@ -720,29 +814,46 @@ func (h *hci) handleEventData(buf []byte) error { return h.leSetAdvertiseEnable(false) case leMetaEventAdvertisingReport: + // Validate the whole report before touching h.advData, so a + // truncated one cannot leave a half filled report behind marked as + // reported. The fixed part is 13 bytes (up to and including the + // data length), followed by the advertisement data and one RSSI + // byte. + if len(buf) < 13 { + if debug { + println("invalid advertising report length", len(buf)) + } + return ErrHCIInvalidPacket + } + + eirLength := buf[12] + + // Note: the length must be computed as an int. Doing the + // arithmetic on the uint8 eirLength would wrap around. + if eirLength > 31 || 13+int(eirLength)+1 > len(buf) { + if debug { + println("invalid packet length", eirLength, len(buf)) + } + return ErrHCIInvalidPacket + } + h.advData.reported = true h.advData.numReports = buf[3] h.advData.typ = buf[4] h.advData.peerBdaddrType = buf[5] - copy(h.advData.peerBdaddr[0:], buf[6:]) - h.advData.eirLength = buf[12] + copy(h.advData.peerBdaddr[0:], buf[6:12]) + h.advData.eirLength = eirLength h.advData.rssi = 0 if debug { println("leMetaEventAdvertisingReport", plen, h.advData.numReports, h.advData.typ, h.advData.peerBdaddrType, h.advData.eirLength) } - if int(13+h.advData.eirLength+1) > len(buf) || h.advData.eirLength > 31 { - if debug { - println("invalid packet length", h.advData.eirLength, len(buf)) - } - return ErrHCIInvalidPacket - } - copy(h.advData.eirData[0:h.advData.eirLength], buf[13:13+h.advData.eirLength]) + copy(h.advData.eirData[0:eirLength], buf[13:13+eirLength]) // TODO: handle multiple reports if h.advData.numReports == 0x01 { - h.advData.rssi = int8(buf[int(13+h.advData.eirLength)]) + h.advData.rssi = int8(buf[13+int(eirLength)]) } return nil @@ -757,6 +868,10 @@ func (h *hci) handleEventData(buf []byte) error { println("leMetaEventRemoteConnParamReq") } + if len(buf) < 13 { + return ErrHCIInvalidPacket + } + connectionHandle := binary.LittleEndian.Uint16(buf[3:]) intervalMin := binary.LittleEndian.Uint16(buf[5:]) intervalMax := binary.LittleEndian.Uint16(buf[7:]) From 1efa9ad3125f387e26b2071416a74e4db27aa746 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Mon, 3 Aug 2026 15:08:13 -0700 Subject: [PATCH 4/5] hci: fix read buffer growth that broke scanning on CYW43439 poll advanced its parse cursor a byte at a time while reading another packet on every iteration, so reads outpaced parsing and the buffer ratcheted full. Once it did, the read was clamped to the space left, which the CYW43439 rejects because it hands back whole packets. Advance the cursor to everything that has been read, only read when there is room for a full packet, and size the buffer for the largest packet plus the transport's header and alignment padding. Signed-off-by: deadprogram --- hci.go | 121 +++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 46 deletions(-) diff --git a/hci.go b/hci.go index 44807cb..6fae349 100644 --- a/hci.go +++ b/hci.go @@ -151,13 +151,28 @@ type hci struct { pendingPkt uint16 } -// hciReadBufSize is the size of the packet read buffer. It must be large -// enough for the largest packet that can legitimately be received, otherwise -// such a packet can never be assembled: an HCI event carries a single byte -// parameter length, so the largest one is the packet type byte, the event code, -// the length byte and 255 parameter bytes. This also covers the largest ACL -// packet that can arrive given maximumMTU. -const hciReadBufSize = hciEvtLenPos + 255 + 1 +// hciMaxPacketSize is the largest packet that can legitimately be received. An +// HCI event carries a single byte parameter length, so the largest one is the +// packet type byte, the event code, the length byte and 255 parameter bytes. +// This also covers the largest ACL packet that can arrive given maximumMTU. +const hciMaxPacketSize = hciEvtLenPos + 255 + 1 + +// hciTransportOverhead is the extra room a transport may need in the +// destination buffer on top of the bytes it hands back. The CYW43439 copies a +// whole entry out of its ring buffer before stripping the 3 byte SDIO header, +// and rounds the copy up to a 4 byte boundary. +const hciTransportOverhead = 3 + +// hciReadBufSize is the size of the packet read buffer. It must be large enough +// for the largest packet that can legitimately be received plus any transport +// overhead, otherwise such a packet can never be assembled. +const hciReadBufSize = (hciMaxPacketSize + hciTransportOverhead + 3) &^ 3 + +// alignUp4 rounds n up to a multiple of 4. Packet oriented transports read in +// 4 byte units, so the destination has to have room for the rounded up size. +func alignUp4(n int) int { + return (n + 3) &^ 3 +} func newHCI(t hciTransport) *hci { return &hci{ @@ -171,22 +186,24 @@ func (h *hci) start() error { h.transport.startRead() defer h.transport.endRead() - var data [32]byte for { - if i := h.transport.Buffered(); i > 0 { - if i > len(data) { - i = len(data) - } - if _, err := h.transport.Read(data[:i]); err != nil { - return err - } + available := h.transport.Buffered() + if available == 0 { + return nil + } - continue + // Discard whatever is left over. The read goes into the packet buffer + // rather than a small scratch buffer because a packet oriented + // transport hands back a whole packet at a time and fails the read + // outright when it does not fit. + aligned := alignUp4(available) + if aligned > len(h.buf) { + return ErrHCIInvalidPacket + } + if _, err := h.transport.Read(h.buf[:aligned]); err != nil { + return err } - return nil } - - return nil } func (h *hci) stop() error { @@ -201,33 +218,43 @@ func (h *hci) poll() error { h.transport.startRead() defer h.transport.endRead() - for h.transport.Buffered() > 0 || h.end > h.pos { + for { + // noRoom records that data is waiting but does not fit alongside what + // is already buffered. + noRoom := false + // perform read only if more data is available - available := h.transport.Buffered() - if available > 0 { - // limit to buffer size - if available > len(h.buf)-h.end { - available = len(h.buf) - h.end - } + if available := h.transport.Buffered(); available > 0 { + // Read in 4 byte aligned chunks. A packet oriented transport hands + // back a whole packet at a time and fails the read outright when it + // does not fit, so only read when there is room for all of it and + // leave the rest until the buffer has been drained. + aligned := alignUp4(available) + switch { + case h.end+aligned <= len(h.buf): + n, err := h.transport.Read(h.buf[h.end : h.end+aligned]) + if err != nil { + return err + } + h.end += n + case h.end == 0: + // There is nothing to drain, so this can never be read. + if debug { + println("hci poll packet too large:", available) + } - // Read in 4 byte aligned chunks. Rounding up may push the read - // past the end of the buffer, so clamp it back down: transports - // read up to the length of the slice, so a short read is fine but - // an out of bounds slice is not. - aligned := available + (4-(available%4))%4 - if h.end+aligned > len(h.buf) { - aligned = len(h.buf) - h.end + return ErrHCIInvalidPacket + default: + noRoom = true } + } - n, err := h.transport.Read(h.buf[h.end : h.end+aligned]) - if err != nil { - return err - } - h.end += n + if h.end == 0 { + return nil } // do processing - h.pos += 1 + h.pos = h.end done, err := h.processPacket() switch { case err == ErrHCIInvalidPacket || err == ErrHCIUnknown || err == ErrHCIUnknownEvent: @@ -257,7 +284,9 @@ func (h *hci) poll() error { h.pos = 0 return nil - case h.pos > h.end: + case noRoom: + // The buffer holds an incomplete packet and there is no room left + // to read the rest of it, so it can never be completed. if debug { println("hci poll buffer overflow", hex.EncodeToString(h.buf[:h.end])) } @@ -265,12 +294,12 @@ func (h *hci) poll() error { h.end = 0 time.Sleep(5 * time.Millisecond) - default: - time.Sleep(1 * time.Millisecond) + case h.transport.Buffered() == 0: + // Incomplete packet with nothing more to read for now. Keep it and + // pick up where we left off on the next poll. + return nil } } - - return nil } func (h *hci) processPacket() (bool, error) { @@ -283,7 +312,7 @@ func (h *hci) processPacket() (bool, error) { // byte. The length comes off the wire, so it may be larger than // the read buffer can ever hold. pktTotal := hciACLLenPos + pktlen + 1 - if pktTotal > len(h.buf) { + if pktTotal > hciMaxPacketSize { return true, ErrHCIInvalidPacket } @@ -306,7 +335,7 @@ func (h *hci) processPacket() (bool, error) { pktlen := int(h.buf[hciEvtLenPos]) pktTotal := hciEvtLenPos + pktlen + 1 - if pktTotal > len(h.buf) { + if pktTotal > hciMaxPacketSize { return true, ErrHCIInvalidPacket } From 43e024445e079c51af9d6011d99cb73146b163c9 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Mon, 3 Aug 2026 21:34:54 -0700 Subject: [PATCH 5/5] hci: add SetScanType and default to active scanning Scanning was hardcoded to passive, so peripherals that only put their complete local name in the scan response were reported without one. Default to active scanning instead, and pass our own address type in the scan parameters so the controller can fill in the SCAN_REQ. Add Adapter.SetScanType to select ScanTypeActive or ScanTypePassive before calling Scan, for callers that want the lower power use and silence of listening only. Signed-off-by: deadprogram --- adapter_hci.go | 29 +++++++++++++++++++++++++++++ examples/scanner/main.go | 6 ++++++ gap_hci.go | 11 +++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/adapter_hci.go b/adapter_hci.go index 08b6c07..8dc2f5e 100644 --- a/adapter_hci.go +++ b/adapter_hci.go @@ -16,6 +16,7 @@ type hciAdapter struct { isDefault bool scanning bool + scanType ScanType connectHandler func(device Device, connected bool) @@ -62,6 +63,34 @@ func (a *hciAdapter) Address() (MACAddress, error) { return a.hci.address, nil } +// ScanType selects whether the scanner transmits while scanning. +type ScanType uint8 + +const ( + // ScanTypeActive sends a SCAN_REQ to advertisers that allow it, so scan + // response data (usually the complete local name) is reported as well. + // This is the default. + ScanTypeActive ScanType = iota + + // ScanTypePassive only listens. It uses less power and does not reveal the + // scanner's presence, but misses scan response data. + ScanTypePassive +) + +// hciValue returns the scan_type field for HCI LE Set Scan Parameters. +func (t ScanType) hciValue() uint8 { + if t == ScanTypePassive { + return 0x00 + } + return 0x01 +} + +// SetScanType sets the scan type used by Scan. Call it before Scan; changing it +// during a scan takes effect on the next call to Scan. +func (a *Adapter) SetScanType(t ScanType) { + a.scanType = t +} + func (a *Adapter) SetRandomAddress(mac MAC) error { if err := a.hci.sendCommandWithParams(ogfLECtrl<