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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
ORDER BY / LIMIT, RETURNING INTO), `FunctionCall.Extras`,
`BinaryExpression.Extras` and `TableReference.Modifiers` (including derived
`VALUES`). Read them through `DialectExtras()` / `DialectModifiers()`, which
return a shared read-only empty value when the pointer is nil, and call
return values for read access without shared mutable stand-ins; call
`EnsureExtras()` / `EnsureModifiers()` before writing.
- **Transpilation**: string literals and quoted identifiers are rendered using
the target dialect's escape rules and delimiters. Unrepresentable literals
Expand All @@ -64,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
in dialects without them.

### Fixed
- Scan raw dialect statements and standalone VALUES for injection patterns, recover
panics at every C export, and guard unlinked tokenizer scratch calls.
- Formatter lower-case mode covers expression and built-in function keywords;
comments use LF endings and keep SQL Server batch boundaries. Fix MySQL join
comma spacing, SELECT alias qualifiers, and TOP/FETCH limit transforms.
Expand Down
9 changes: 9 additions & 0 deletions internal/tokscratch/tokscratch.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,27 @@ var impl Func
// tokenizer's init and must not be called by anyone else.
func Register(f Func) { impl = f }

func requireImplementation() {
if impl == nil {
panic("tokscratch: tokenizer not linked")
}
}

// Bytes is t.Tokenize(input) into t's scratch buffer.
func Bytes(t any, input []byte) ([]models.TokenWithSpan, error) {
requireImplementation()
return impl(context.Background(), t, input, "", false)
}

// String is t.TokenizeString(s) into t's scratch buffer.
func String(t any, s string) ([]models.TokenWithSpan, error) {
requireImplementation()
return impl(context.Background(), t, nil, s, true)
}

// StringContext is t.TokenizeStringContext(ctx, s) into t's scratch buffer.
func StringContext(ctx context.Context, t any, s string) ([]models.TokenWithSpan, error) {
requireImplementation()
if err := ctx.Err(); err != nil {
return nil, err
}
Expand Down
28 changes: 28 additions & 0 deletions internal/tokscratch/tokscratch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package tokscratch

import (
"context"
"testing"
)

func TestTokenizerNotLinked(t *testing.T) {
for _, name := range []string{"Bytes", "String", "StringContext"} {
t.Run(name, func(t *testing.T) {
defer func() {
r := recover()
message, ok := r.(string)
if !ok || message != "tokscratch: tokenizer not linked" {
t.Fatalf("panic = %v, want tokscratch: tokenizer not linked", r)
}
}()
switch name {
case "Bytes":
_, _ = Bytes(nil, nil)
case "String":
_, _ = String(nil, "")
case "StringContext":
_, _ = StringContext(context.Background(), nil, "")
}
})
}
}
37 changes: 29 additions & 8 deletions pkg/cbinding/cbinding.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ func statementTypeName(stmt ast.Statement) string {
// pointer with gosqlx_free.
//
//export gosqlx_parse
func gosqlx_parse(sql *C.char) *C.char {
func gosqlx_parse(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_parse")
return toJSON(parseResultFor(C.GoString(sql), ""))
}

Expand All @@ -222,7 +224,9 @@ func gosqlx_parse(sql *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_validate
func gosqlx_validate(sql *C.char) *C.char {
func gosqlx_validate(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicValidationJSON)
checkExportPanic("gosqlx_validate")
return toJSON(validationResultFor(C.GoString(sql), ""))
}

Expand All @@ -237,7 +241,9 @@ func gosqlx_validate(sql *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_format
func gosqlx_format(sql *C.char) *C.char {
func gosqlx_format(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_format")
return toJSON(formatResultFor(C.GoString(sql), ""))
}

Expand All @@ -252,7 +258,9 @@ func gosqlx_format(sql *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_tables
func gosqlx_extract_tables(sql *C.char) *C.char {
func gosqlx_extract_tables(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_tables")
return toJSON(extractListFor(C.GoString(sql), "", "tables", gosqlx.ExtractTables))
}

Expand All @@ -267,7 +275,9 @@ func gosqlx_extract_tables(sql *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_columns
func gosqlx_extract_columns(sql *C.char) *C.char {
func gosqlx_extract_columns(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_columns")
return toJSON(extractListFor(C.GoString(sql), "", "columns", gosqlx.ExtractColumns))
}

Expand All @@ -282,7 +292,9 @@ func gosqlx_extract_columns(sql *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_functions
func gosqlx_extract_functions(sql *C.char) *C.char {
func gosqlx_extract_functions(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_functions")
return toJSON(extractListFor(C.GoString(sql), "", "functions", gosqlx.ExtractFunctions))
}

Expand All @@ -297,7 +309,9 @@ func gosqlx_extract_functions(sql *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_metadata
func gosqlx_extract_metadata(sql *C.char) *C.char {
func gosqlx_extract_metadata(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_metadata")
return toJSON(metadataFor(C.GoString(sql), ""))
}

Expand All @@ -308,12 +322,16 @@ func gosqlx_extract_metadata(sql *C.char) *C.char {
// *_dialect variant, gosqlx_lint, gosqlx_security_scan, gosqlx_transpile,
// gosqlx_dialects and gosqlx_version) must be freed exactly once with this
// function to avoid memory leaks.
// Recoverable Go panics are contained at this boundary; this void function
// has no error return channel.
//
// Parameters:
// - ptr: a C string pointer previously returned by a gosqlx_* function.
//
//export gosqlx_free
func gosqlx_free(ptr *C.char) {
defer recoverExport(nil, "")
checkExportPanic("gosqlx_free")
C.free(unsafe.Pointer(ptr))
}

Expand All @@ -324,9 +342,12 @@ func gosqlx_free(ptr *C.char) {
// other gosqlx_* result, the caller owns it and must free it exactly once with
// gosqlx_free. (Earlier releases returned a process-wide cached pointer here,
// which made the documented free a double free on the second call.)
// Returns NULL if a recoverable Go panic occurs.
//
//export gosqlx_version
func gosqlx_version() *C.char {
func gosqlx_version() (result *C.char) {
defer recoverExport(&result, "")
checkExportPanic("gosqlx_version")
return C.CString(VERSION)
}

Expand Down
44 changes: 33 additions & 11 deletions pkg/cbinding/cbinding_ext.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,9 @@ func metadataFor(sql, dialect string) MetadataResult {
// pointer with gosqlx_free.
//
//export gosqlx_parse_dialect
func gosqlx_parse_dialect(sql, dialect *C.char) *C.char {
func gosqlx_parse_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_parse_dialect")
return toJSON(parseResultFor(C.GoString(sql), C.GoString(dialect)))
}

Expand All @@ -276,7 +278,9 @@ func gosqlx_parse_dialect(sql, dialect *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_validate_dialect
func gosqlx_validate_dialect(sql, dialect *C.char) *C.char {
func gosqlx_validate_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicValidationJSON)
checkExportPanic("gosqlx_validate_dialect")
return toJSON(validationResultFor(C.GoString(sql), C.GoString(dialect)))
}

Expand All @@ -289,7 +293,9 @@ func gosqlx_validate_dialect(sql, dialect *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_format_dialect
func gosqlx_format_dialect(sql, dialect *C.char) *C.char {
func gosqlx_format_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_format_dialect")
return toJSON(formatResultFor(C.GoString(sql), C.GoString(dialect)))
}

Expand All @@ -301,7 +307,9 @@ func gosqlx_format_dialect(sql, dialect *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_tables_dialect
func gosqlx_extract_tables_dialect(sql, dialect *C.char) *C.char {
func gosqlx_extract_tables_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_tables_dialect")
return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "tables", gosqlx.ExtractTables))
}

Expand All @@ -313,7 +321,9 @@ func gosqlx_extract_tables_dialect(sql, dialect *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_columns_dialect
func gosqlx_extract_columns_dialect(sql, dialect *C.char) *C.char {
func gosqlx_extract_columns_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_columns_dialect")
return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "columns", gosqlx.ExtractColumns))
}

Expand All @@ -326,7 +336,9 @@ func gosqlx_extract_columns_dialect(sql, dialect *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_functions_dialect
func gosqlx_extract_functions_dialect(sql, dialect *C.char) *C.char {
func gosqlx_extract_functions_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_functions_dialect")
return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "functions", gosqlx.ExtractFunctions))
}

Expand All @@ -339,7 +351,9 @@ func gosqlx_extract_functions_dialect(sql, dialect *C.char) *C.char {
// pointer with gosqlx_free.
//
//export gosqlx_extract_metadata_dialect
func gosqlx_extract_metadata_dialect(sql, dialect *C.char) *C.char {
func gosqlx_extract_metadata_dialect(sql, dialect *C.char) (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_extract_metadata_dialect")
return toJSON(metadataFor(C.GoString(sql), C.GoString(dialect)))
}

Expand Down Expand Up @@ -481,7 +495,9 @@ func lintFor(sql, optionsJSON string) LintResult {
// pointer with gosqlx_free.
//
//export gosqlx_lint
func gosqlx_lint(sql, options_json *C.char) *C.char {
func gosqlx_lint(sql, options_json *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_lint")
return toJSON(lintFor(C.GoString(sql), C.GoString(options_json)))
}

Expand Down Expand Up @@ -540,7 +556,9 @@ func securityScanFor(sql string) SecurityScanResult {
// pointer with gosqlx_free.
//
//export gosqlx_security_scan
func gosqlx_security_scan(sql *C.char) *C.char {
func gosqlx_security_scan(sql *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_security_scan")
return toJSON(securityScanFor(C.GoString(sql)))
}

Expand Down Expand Up @@ -594,7 +612,9 @@ func transpileFor(sql, from, to string) TranspileResult {
// pointer with gosqlx_free.
//
//export gosqlx_transpile
func gosqlx_transpile(sql, from, to *C.char) *C.char {
func gosqlx_transpile(sql, from, to *C.char) (result *C.char) {
defer recoverExport(&result, panicSuccessJSON)
checkExportPanic("gosqlx_transpile")
return toJSON(transpileFor(C.GoString(sql), C.GoString(from), C.GoString(to)))
}

Expand All @@ -615,6 +635,8 @@ type DialectsResult struct {
// pointer with gosqlx_free.
//
//export gosqlx_dialects
func gosqlx_dialects() *C.char {
func gosqlx_dialects() (result *C.char) {
defer recoverExport(&result, panicErrorJSON)
checkExportPanic("gosqlx_dialects")
return toJSON(DialectsResult{Dialects: keywords.DialectNames()})
}
76 changes: 76 additions & 0 deletions pkg/cbinding/cbinding_panic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import "testing"

func TestExportsRecoverPanics(t *testing.T) {
sql, dialect := strPtr("SELECT 1"), strPtr("postgresql")
type panicCase struct {
name string
call func() string
flag string
}
var cases []panicCase
for _, e := range allExports {
flag := ""
if e == exportValidate {
flag = "valid"
} else if e == exportParse || e == exportFormat {
flag = "success"
}
cases = append(cases,
panicCase{"gosqlx_" + e.String(), func() string { return callLegacyRaw(e, sql) }, flag},
panicCase{"gosqlx_" + e.String() + "_dialect", func() string { return callDialectRaw(e, sql, dialect) }, flag})
}
cases = append(cases,
panicCase{"gosqlx_lint", func() string { return lintRaw(*sql, nil) }, "success"},
panicCase{"gosqlx_security_scan", func() string { return securityScanRaw(sql) }, "success"},
panicCase{"gosqlx_transpile", func() string { return transpileRaw(sql, dialect, dialect) }, "success"},
panicCase{"gosqlx_dialects", dialectsRaw, ""})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
exportPanicHook.Store(&tc.name)
defer exportPanicHook.Store(nil)
result := decodeMap(t, tc.call())
if got := errorOf(result); got != "internal error" {
t.Errorf("error = %q, want sanitized internal error", got)
}
if tc.flag != "" && result[tc.flag] != false {
t.Errorf("%s = %v, want false", tc.flag, result[tc.flag])
}
})
}
t.Run("gosqlx_version", func(t *testing.T) {
name := "gosqlx_version"
exportPanicHook.Store(&name)
defer exportPanicHook.Store(nil)
if !versionIsNull() {
t.Fatal("version panic must return NULL")
}
})
t.Run("gosqlx_free", func(t *testing.T) {
name := "gosqlx_free"
exportPanicHook.Store(&name)
defer exportPanicHook.Store(nil)
freeNil()
})
}

func TestExportRecoveryDoesNotAllocate(t *testing.T) {
if got := testing.AllocsPerRun(100, freeNil); got != 0 {
t.Fatalf("normal free boundary allocates: %v", got)
}
}
Loading
Loading