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 .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
go-version: 1.25.3

- name: Unit tests
run: |
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/fuzz.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Fuzz tests

on:
pull_request:
branches:
- main
- develop
schedule:
- cron: '17 2 * * *'
workflow_dispatch:

permissions:
contents: read

jobs:
regression:
name: Regression corpus
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.25.3'
- name: Run regression corpus
run: go test ./...

fuzz:
name: ${{ matrix.target }}
needs: regression
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: FuzzReader
package: ./internal/buffer
corpus: internal/buffer/testdata/fuzz/FuzzReader
- target: FuzzSplitCompoundQuery
package: ./pkg/sqlbackend
corpus: pkg/sqlbackend/testdata/fuzz/FuzzSplitCompoundQuery
- target: FuzzStartup
package: .
corpus: testdata/fuzz/FuzzStartup
- target: FuzzSessionRaw
package: .
corpus: testdata/fuzz/FuzzSessionRaw
- target: FuzzSessionStructured
package: .
corpus: testdata/fuzz/FuzzSessionStructured
- target: FuzzRowEncoding
package: .
corpus: testdata/fuzz/FuzzRowEncoding
env:
FUZZ_TARGET: ${{ matrix.target }}
FUZZ_PACKAGE: ${{ matrix.package }}
FUZZ_TIME: ${{ github.event_name == 'schedule' && '10m' || '60s' }}
GOCACHE: ${{ github.workspace }}/.cache/go-build
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.25.3'
- uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ${{ env.GOCACHE }}/fuzz
key: fuzz-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('go.sum') }}
restore-keys: |
fuzz-${{ runner.os }}-${{ matrix.target }}-
- name: Fuzz target
id: fuzz
run: make fuzz FUZZ_TARGET="$FUZZ_TARGET" FUZZ_PACKAGE="$FUZZ_PACKAGE" TIME="$FUZZ_TIME"
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
if: failure()
with:
name: fuzz-failure-${{ matrix.target }}
path: ${{ matrix.corpus }}/
if-no-files-found: ignore
2 changes: 1 addition & 1 deletion .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
go-version: 1.25.3

- name: Restore bin
uses: actions/cache@v4
Expand Down
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ BUILD_DIR = $(CURDIR)/build
GOPATH = $(HOME)/go
GOBIN = $(GOPATH)/bin
GO ?= GOGC=off $(shell which go)
FUZZ_GO = $(subst GOGC=off ,,$(GO))

GOLANGCI_LINT_VERSION = v2.5.0

Expand Down Expand Up @@ -39,6 +40,11 @@ lint: | $(GOLANGCI_LINT) ; $(info $(M) running golint…) @ ## Run the project l
test: ## Run all tests
$Q $(GO) test ./...

.PHONY: fuzz
fuzz: ## Run one fuzz target (FUZZ_TARGET=FuzzXxx FUZZ_PACKAGE=./pkg TIME=60s)
@if [ -z "$(FUZZ_TARGET)" ] || [ -z "$(FUZZ_PACKAGE)" ]; then echo "set FUZZ_TARGET and FUZZ_PACKAGE"; exit 2; fi
$Q GOGC=100 $(FUZZ_GO) test -run=^$$ -fuzz=^$(FUZZ_TARGET)$$ -fuzztime=$(TIME) -parallel=1 $(FUZZ_PACKAGE)

.PHONY: fmt
fmt: ; $(info $(M) running gofmt…) @ ## Run gofmt on all source files
$Q $(GO) fmt $(PKGS)
Expand Down
2 changes: 1 addition & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@
}

func (srv *Server) handleSimpleQuery(ctx context.Context, cn SQLConnection) error {
if srv.SimpleQuery == nil && srv.SQLBackendFactory == nil {
if srv.SimpleQuery == nil && !cn.HasSQLBackend() {
ErrorCode(cn, NewErrUnimplementedMessageType(types.ClientSimpleQuery))

Check failure on line 196 in command.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
return readyForQuery(cn, types.ServerIdle)
}

Expand All @@ -220,14 +220,14 @@
if q == "" {
if i == len(qArr)-1 {
// trailing semicolon, ignore
commandComplete(cn, "OK")

Check failure on line 223 in command.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
return readyForQuery(cn, types.ServerIdle)
}
continue
}
rdr, err := cn.HandleSimpleQuery(ctx, q)
if err != nil {
ErrorCode(cn, err)

Check failure on line 230 in command.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
return readyForQuery(cn, types.ServerIdle)
}
dw := &dataWriter{
Expand Down Expand Up @@ -255,7 +255,7 @@
})

if err != nil {
ErrorCode(cn, err)

Check failure on line 258 in command.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
return readyForQuery(cn, types.ServerIdle)
}

Expand Down
57 changes: 57 additions & 0 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Fuzzing

The project uses Go's native fuzzing support. Fuzz tests and deterministic
regression seeds live beside the packages they exercise; the ordinary
`go test ./...` run executes every seed.

## Run a target

Go runs one fuzz target per invocation. Run a target for a time limit with
`make fuzz`, naming both the target and its package:

```sh
make fuzz FUZZ_TARGET=FuzzSessionStructured FUZZ_PACKAGE=. TIME=5m
make fuzz FUZZ_TARGET=FuzzReader FUZZ_PACKAGE=./internal/buffer TIME=5m
```

The available targets are `FuzzReader` (`./internal/buffer`),
`FuzzSplitCompoundQuery` (`./pkg/sqlbackend`), and the root-package targets
`FuzzStartup`, `FuzzSessionRaw`, `FuzzSessionStructured`, and `FuzzRowEncoding`.
The CI workflow runs the regression corpus on every pull request, then fuzzes
each target for one minute. Its nightly run fuzzes each target for ten minutes
and caches Go's generated corpus outside the repository.

## Add a target or seed

Add a `FuzzXxx(*testing.F)` test beside the package code. Seed it with
`f.Add` or add a Go fuzz corpus file under `testdata/fuzz/FuzzXxx/`. Keep
inputs synthetic and deterministic. For protocol targets, exercise complete
message sequences as well as malformed framing, and check responses with an
independent decoder. State protocol invariants in the test with a link to the
relevant PostgreSQL protocol section.

The root integration tests can optionally capture client-to-server bytes and
write sanitized startup and command seeds:

```sh
PSQL_WIRE_RECORD_FUZZ_SEEDS=1 go test -run '^TestClientConnect$' .
```

The capture code replaces client startup parameters with synthetic values
before writing a seed. Review every resulting corpus file before committing it;
never commit passwords, hostnames, real query text, or other environment data.

## Triage a failure

Go writes a minimized failing input under the target's `testdata/fuzz/FuzzXxx/`
directory. Reproduce it with the command printed by Go, or run all deterministic
regressions with:

```sh
go test ./...
```

Keep the failing input as a regression seed after fixing the underlying defect.
Do not delete a seed or narrow a target just to make the fuzz run pass. Review
the minimized bytes, identify the violated invariant or panic, make the
smallest correct fix, and rerun both the specific target and the full tests.
16 changes: 14 additions & 2 deletions format.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,23 @@ type FormatCode int16

// Encoder returns the format encoder for the given data type
func (code FormatCode) Encoder(t *pgtype.DataType) FormatEncoder {
if t == nil || t.Value == nil {
return unknownEncoderfunc(fmt.Errorf("format %d has no data type value", code))
}

switch code {
case TextFormat:
return t.Value.(pgtype.TextEncoder).EncodeText
encoder, ok := t.Value.(pgtype.TextEncoder)
if !ok {
return unknownEncoderfunc(fmt.Errorf("data type %q does not support text encoding", t.Name))
}
return encoder.EncodeText
case BinaryFormat:
return t.Value.(pgtype.BinaryEncoder).EncodeBinary
encoder, ok := t.Value.(pgtype.BinaryEncoder)
if !ok {
return unknownEncoderfunc(fmt.Errorf("data type %q does not support binary encoding", t.Name))
}
return encoder.EncodeBinary
default:
return unknownEncoderfunc(fmt.Errorf("unknown format encoder %d", code))
}
Expand Down
19 changes: 18 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/stackql/psql-wire

go 1.16
go 1.25.3

require (
github.com/jackc/pgtype v1.8.1
Expand All @@ -10,3 +10,20 @@ require (
go.uber.org/zap v1.19.1
golang.org/x/tools v0.1.5
)

require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
golang.org/x/mod v0.4.2 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
Expand All @@ -36,7 +35,6 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
Expand Down
5 changes: 5 additions & 0 deletions internal/buffer/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"unsafe"

Expand Down Expand Up @@ -191,6 +192,10 @@ func (reader *simpleReader) GetPrepareType() (PrepareType, error) {

// GetBytes returns the buffer's contents as a []byte.
func (reader *simpleReader) GetBytes(n int) ([]byte, error) {
if n < 0 {
return nil, fmt.Errorf("negative byte count %d", n)
}

if len(reader.Msg) < n {
return nil, NewInsufficientData(len(reader.Msg))
}
Expand Down
40 changes: 40 additions & 0 deletions internal/buffer/reader_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package buffer

import (
"bytes"
"testing"
)

func FuzzReader(f *testing.F) {
f.Add([]byte{0xff})
f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7})
f.Add([]byte{0, 0, 0, 4, 'Q', 0, 0, 0})

f.Fuzz(func(t *testing.T, input []byte) {
reader := CreateTestReader(input, nil)
if len(input) > 0 {
_, _ = reader.GetBytes(int(int8(input[0])))
}
for i, op := range input {
switch op % 4 {
case 0:
n := int(int8(op))
_, _ = reader.GetBytes(n)
case 1:
_, _ = reader.GetString()
case 2:
_, _ = reader.GetUint16()
case 3:
_, _ = reader.GetUint32()
}
if i >= 127 {
break
}
}

untyped := NewReader(bytes.NewReader(input), 256)
_, _ = untyped.ReadUntypedMsg()
typed := NewReader(bytes.NewReader(input), 256)
_, _, _ = typed.ReadTypedMsg()
})
}
15 changes: 15 additions & 0 deletions internal/buffer/reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ func TestGetStringNulTerminatorNotfound(t *testing.T) {
}
}

func TestGetBytesNegativeCount(t *testing.T) {
reader := CreateTestReader([]byte("data"), nil)

value, err := reader.GetBytes(-1)
if err == nil {
t.Fatal("expected an error for a negative byte count")
}
if value != nil {
t.Fatalf("unexpected result for a negative byte count: %q", value)
}
if got := string(reader.PeekMsg()); got != "data" {
t.Fatalf("negative byte count consumed data: got %q", got)
}
}

func TestGetInsufficientData(t *testing.T) {
buffer := bytes.NewBuffer([]byte{})
reader := CreateTestReader(
Expand Down
2 changes: 2 additions & 0 deletions internal/buffer/testdata/fuzz/FuzzReader/negative-byte-count
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("\xff")
24 changes: 24 additions & 0 deletions pkg/sqlbackend/pgsqlbackend_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package sqlbackend

import (
"strings"
"testing"
)

func FuzzSplitCompoundQuery(f *testing.F) {
f.Add("")
f.Add("select 1;select 2")
f.Add(`select "a;b";select "c\";d"`)
f.Add(";;;")

f.Fuzz(func(t *testing.T, query string) {
backend := NewSimpleSQLBackend(nil)
parts, err := backend.SplitCompoundQuery(query)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(parts, ";"); got != query {
t.Fatalf("joining split query changed it: got %q, want %q", got, query)
}
})
}
Loading
Loading