From cc162a8ef32c3e2bc9cf765a3862e417ad8221f4 Mon Sep 17 00:00:00 2001 From: James Tucker Date: Tue, 6 Jan 2026 17:04:32 -0800 Subject: [PATCH] cgsqlite,.: fix regression with empty strings A final step refactor in 81bdfd09bc8b066266d470291f00ec589f1c2403 introduced a bug where empty strings became NULL in some code paths. This fixes the regression and introduces a test to prevent reoccurrence. Updates tailscale/corp#9199 --- cgosqlite/cgosqlite.go | 6 +++-- sqlite_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cgosqlite/cgosqlite.go b/cgosqlite/cgosqlite.go index e68c4bb..eff9a12 100644 --- a/cgosqlite/cgosqlite.go +++ b/cgosqlite/cgosqlite.go @@ -57,7 +57,9 @@ import ( "github.com/tailscale/sqlite/sqliteh" ) -var emptyStrPtr = (*C.char)(unsafe.Pointer(unsafe.StringData(""))) +// emptyChar is the empty string constant used when binding empty strings to +// avoid the need to allocate new storage in each invocation. +var emptyChar [1]C.char func init() { C.sqlite3_initialize() @@ -298,7 +300,7 @@ func (stmt *Stmt) BindNull(col int) error { func (stmt *Stmt) BindText64(col int, val string) error { if len(val) == 0 { - return errCode(C.sqlite3_bind_text64(stmt.stmt, C.int(col), emptyStrPtr, 0, C.SQLITE_STATIC, C.SQLITE_UTF8)) + return errCode(C.sqlite3_bind_text64(stmt.stmt, C.int(col), &emptyChar[0], 0, C.SQLITE_STATIC, C.SQLITE_UTF8)) } v := C.CString(val) // freed by sqlite return errCode(C.sqlite3_bind_text64(stmt.stmt, C.int(col), v, C.sqlite3_uint64(len(val)), (*[0]byte)(C.free), C.SQLITE_UTF8)) diff --git a/sqlite_test.go b/sqlite_test.go index a9f85d5..de6fab9 100644 --- a/sqlite_test.go +++ b/sqlite_test.go @@ -335,6 +335,63 @@ func TestEmptyString(t *testing.T) { } } +// TestEmptyStringNotNull verifies that binding an empty string results in an +// empty string value, not NULL. This is a regression test for a bug where +// unsafe.StringData("") could return nil, causing sqlite3_bind_text64 to +// receive a NULL pointer and treat the value as NULL instead of "". +func TestEmptyStringNotNull(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + exec(t, db, "CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)") + exec(t, db, "INSERT INTO t (id, val) VALUES (?, ?)", 1, "") + exec(t, db, "INSERT INTO t (id, val) VALUES (?, ?)", 2, nil) + exec(t, db, "INSERT INTO t (id, val) VALUES (?, ?)", 3, "ok") + + var val sql.NullString + if err := db.QueryRowContext(ctx, "SELECT val FROM t WHERE id = 1").Scan(&val); err != nil { + t.Fatal(err) + } + if !val.Valid { + t.Fatal("empty string was stored as NULL") + } + if val.String != "" { + t.Fatalf("val=%q, want empty string", val.String) + } + + if err := db.QueryRowContext(ctx, "SELECT val FROM t WHERE id = 2").Scan(&val); err != nil { + t.Fatal(err) + } + if val.Valid { + t.Fatalf("NULL was stored as %q", val.String) + } + + var countEmpty, countNull int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM t WHERE val = ''").Scan(&countEmpty); err != nil { + t.Fatal(err) + } + if countEmpty != 1 { + t.Fatalf("countEmpty=%d, want 1", countEmpty) + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM t WHERE val IS NULL").Scan(&countNull); err != nil { + t.Fatal(err) + } + if countNull != 1 { + t.Fatalf("countNull=%d, want 1", countNull) + } + + var length sql.NullInt64 + if err := db.QueryRowContext(ctx, "SELECT length(val) FROM t WHERE id = 1").Scan(&length); err != nil { + t.Fatal(err) + } + if !length.Valid { + t.Fatal("length of empty string returned NULL") + } + if length.Int64 != 0 { + t.Fatalf("length=%d, want 0", length.Int64) + } +} + func TestExecScript(t *testing.T) { db := openTestDB(t) conn, err := db.Conn(context.Background())