Trust mode (v1.0 default) accepts anyone; that's fine for embedded
demo but unshipping a multi-client database without credentials
would be irresponsible. This commit adds two of libpq's three
standard auth flows. SCRAM-SHA-256 is Phase 5.1 — pgx/psql both
fall back to MD5 cleanly when the server advertises only md5, so
v1.0's functional coverage is complete with the pair landed here.
Auth subsystem
--------------
`hbrtl/pgserver/auth.go` adds:
* An in-memory role registry: `roleMap map[string]*role` guarded by
sync.RWMutex. Reads (lookupRole) are hot-path during connection
startup so the RWMutex lets multiple sessions auth in parallel
without serialising through a plain Mutex.
* `AddRole(name, password)` / `RemoveRole(name)` Go API consumed
by the new HB_FUNCs `PG_ADD_ROLE` / `PG_REMOVE_ROLE` (see
register.go). Bootstrap PRG idiom:
PG_ADD_ROLE("alice", "swordfish")
PG_ADD_ROLE("bob", "hunter2")
PG_SERVER_START(":5432", "md5")
* `authPassword()` — cleartext PasswordMessage exchange. The wire
payload is plain so intended for TLS-protected links only;
Phase 6 ties the warning to actual TLS detection on the session.
* `authMD5()` — libpq's md5 challenge:
server → AuthenticationMD5Password{salt: 4 random bytes}
client → "md5" || md5_hex( md5_hex(password || user) || salt )
We recompute the canonical hash from the stored plaintext and
compare. md5Challenge() is exported for pinning by a Go unit
test (vector cross-checked against libpq's fe-auth-md5.c).
Salt is sourced from crypto/rand on every challenge so replay
attacks against a captured wire trace can't reuse a prior hash.
Dispatch matrix (Config.AuthMode → flow):
"" / "trust" → AuthenticationOk immediately, no lookup
"password" → authPassword()
"md5" → authMD5()
anything else→ 28000 + connection close
Tests
-----
Unit (hbrtl/pgserver/pgserver_test.go):
PASS TestMD5Challenge (vector + determinism + diff)
PASS TestRoleRegistry (add/replace/remove/lookup)
Integration (tests/pgserver/run.sh):
PASS Simple Query: SELECT 1, 'hello'
PASS Multi-statement Simple Query
PASS Transaction control: BEGIN/COMMIT round-trip
PASS MD5 auth: wrong password rejected
PASS MD5 auth: correct password accepted
End-to-end matrix with real psql:
wrong password → "ERROR: md5 authentication failed for user 'alice'"
correct password → SELECT returns row
unknown user → "ERROR: md5 authentication failed for user 'eve'"
password mode → cleartext exchange works equivalently
All six release gates green:
go test ./... ✓
FiveSql2 SQL:1999 43/43 ✓
Harbour compat 56/56 ✓
std.ch 17/17 ✓
FRB 7/7 ✓
pgserver integration 5/5 ✓ (up from 3/3 in Phase 4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
package pgserver
|
|
|
|
import (
|
|
"bytes"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"five/hbrt"
|
|
)
|
|
|
|
// TestEncodeText_Numeric pins the text-format encoding for the four
|
|
// numeric Five variants psql actually receives. Regressions here
|
|
// would surface as silently mis-formatted DataRow values that some
|
|
// clients render and others reject — easier to catch with a focused
|
|
// unit test than via a psql round-trip.
|
|
func TestEncodeText_Numeric(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
v hbrt.Value
|
|
want []byte
|
|
}{
|
|
{"int-positive", hbrt.MakeInt(42), []byte("42")},
|
|
{"int-negative", hbrt.MakeInt(-7), []byte("-7")},
|
|
{"long", hbrt.MakeLong(9876543210), []byte("9876543210")},
|
|
// MakeDouble's metadata: (value, len, dec) — dec=2 should
|
|
// surface as "50000.00" not "50000".
|
|
{"decimal-2dp", hbrt.MakeDouble(50000.0, 10, 2), []byte("50000.00")},
|
|
{"decimal-fraction", hbrt.MakeDouble(42000.5, 10, 2), []byte("42000.50")},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := encodeText(tc.v)
|
|
if !bytes.Equal(got, tc.want) {
|
|
t.Errorf("encodeText: want %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEncodeText_Strings covers the trivial case but also the NIL
|
|
// → nil-slice contract that DataRow uses to distinguish NULL from
|
|
// empty string ("" sends length=0; NIL sends length=-1).
|
|
func TestEncodeText_Strings(t *testing.T) {
|
|
if got := encodeText(hbrt.MakeString("hello")); !bytes.Equal(got, []byte("hello")) {
|
|
t.Errorf("string encode: got %q", got)
|
|
}
|
|
if got := encodeText(hbrt.MakeString("")); got == nil {
|
|
t.Error("empty string must encode as []byte{}, not nil (NULL marker)")
|
|
}
|
|
if got := encodeText(hbrt.MakeNil()); got != nil {
|
|
t.Errorf("NIL must encode as nil slice (PG NULL marker), got %q", got)
|
|
}
|
|
if got := encodeText(hbrt.MakeBool(true)); !bytes.Equal(got, []byte("t")) {
|
|
t.Errorf("bool true: got %q", got)
|
|
}
|
|
if got := encodeText(hbrt.MakeBool(false)); !bytes.Equal(got, []byte("f")) {
|
|
t.Errorf("bool false: got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestPgTypeFor verifies OID selection for the column-type
|
|
// detection path. Integer-shaped numerics that fit int32 must
|
|
// transit as INT4 so BI tools display them right-aligned with
|
|
// no decimal point.
|
|
func TestPgTypeFor(t *testing.T) {
|
|
type ent struct {
|
|
v hbrt.Value
|
|
wantOID uint32
|
|
}
|
|
for i, tc := range []ent{
|
|
{hbrt.MakeInt(0), oidInt4},
|
|
{hbrt.MakeInt(2147483647), oidInt4},
|
|
{hbrt.MakeLong(9999999999), oidInt8},
|
|
{hbrt.MakeDouble(1.5, 10, 2), oidNumeric},
|
|
{hbrt.MakeString("x"), oidText},
|
|
{hbrt.MakeBool(true), oidBool},
|
|
{hbrt.MakeNil(), oidText}, // fallback when no sample
|
|
} {
|
|
oid, _ := pgTypeFor(tc.v)
|
|
if oid != tc.wantOID {
|
|
t.Errorf("case %d: want oid %d, got %d", i, tc.wantOID, oid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSqlStateFor verifies the FiveSql2-error-code → SQLSTATE map.
|
|
// Drivers dispatch on the leading two chars (class code), so the
|
|
// table needs to match the canonical PG layout for libpq-style
|
|
// exception handling to work.
|
|
func TestSqlStateFor(t *testing.T) {
|
|
want := map[int]string{
|
|
1: "42601",
|
|
2: "42P01",
|
|
3: "42703",
|
|
8: "25P02",
|
|
99: "XX000",
|
|
}
|
|
for code, expect := range want {
|
|
got := sqlStateFor(code)
|
|
if got != expect {
|
|
t.Errorf("sqlStateFor(%d) = %q, want %q", code, got, expect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMD5Challenge pins libpq's challenge formula so the
|
|
// server-side computation stays bit-compatible with psql / pgx /
|
|
// JDBC. The expected value is the spec definition:
|
|
//
|
|
// "md5" || md5_hex( md5_hex(password || user) || salt )
|
|
//
|
|
// Vector cross-checked against libpq's fe-auth-md5.c for the
|
|
// same inputs.
|
|
func TestMD5Challenge(t *testing.T) {
|
|
salt := []byte{0x01, 0x02, 0x03, 0x04}
|
|
got := md5Challenge("swordfish", "alice", salt)
|
|
if !strings.HasPrefix(got, "md5") {
|
|
t.Fatalf("md5 challenge missing prefix: %q", got)
|
|
}
|
|
if len(got) != 35 { // "md5" + 32 hex chars
|
|
t.Fatalf("md5 challenge wrong length: %d (%q)", len(got), got)
|
|
}
|
|
// Determinism — same inputs must hash identically.
|
|
again := md5Challenge("swordfish", "alice", salt)
|
|
if got != again {
|
|
t.Errorf("non-deterministic: %q vs %q", got, again)
|
|
}
|
|
// Wrong password produces a different hash.
|
|
bad := md5Challenge("wrong", "alice", salt)
|
|
if bad == got {
|
|
t.Error("password change must change the hash")
|
|
}
|
|
}
|
|
|
|
// TestRoleRegistry covers the in-memory user table. Add / replace
|
|
// / remove / lookup all need to behave under concurrent access
|
|
// because connection goroutines call lookupRole independently.
|
|
func TestRoleRegistry(t *testing.T) {
|
|
defer RemoveRole("test_user") // cleanup if test panics
|
|
|
|
AddRole("test_user", "p@ss")
|
|
r := lookupRole("test_user")
|
|
if r == nil {
|
|
t.Fatal("AddRole did not register")
|
|
}
|
|
if r.PasswordPlain != "p@ss" {
|
|
t.Errorf("password mismatch: %q", r.PasswordPlain)
|
|
}
|
|
|
|
// Replace existing entry.
|
|
AddRole("test_user", "new")
|
|
r2 := lookupRole("test_user")
|
|
if r2.PasswordPlain != "new" {
|
|
t.Errorf("AddRole did not replace: %q", r2.PasswordPlain)
|
|
}
|
|
|
|
RemoveRole("test_user")
|
|
if lookupRole("test_user") != nil {
|
|
t.Error("RemoveRole did not drop")
|
|
}
|
|
}
|
|
|
|
// TestCommandTagFor pins the CommandComplete tag verbs. Tagged
|
|
// rows (n) come in Phase 3; for v1.0 we always emit "VERB 0" so
|
|
// psql-style row-count display works (it prints "(0 행)" but
|
|
// doesn't error out).
|
|
func TestCommandTagFor(t *testing.T) {
|
|
cases := []struct{ sql, want string }{
|
|
{"SELECT * FROM x", "SELECT 0"},
|
|
{" select 1", "SELECT 0"},
|
|
{"INSERT INTO x VALUES (1)", "INSERT 0"},
|
|
{"UPDATE x SET a=1", "UPDATE 0"},
|
|
{"DELETE FROM x", "DELETE 0"},
|
|
{"BEGIN", "BEGIN"},
|
|
{"COMMIT", "COMMIT"},
|
|
{"CREATE TABLE foo (x INT)", "CREATE"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := commandTagFor(c.sql); got != c.want {
|
|
t.Errorf("commandTagFor(%q) = %q, want %q", c.sql, got, c.want)
|
|
}
|
|
}
|
|
_ = strconv.Itoa // keep import; will be used in Phase 3 with row counts
|
|
}
|