Changeset 0.29.2 (#197)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
550
server/sandbox/db_module.go
Normal file
550
server/sandbox/db_module.go
Normal file
@@ -0,0 +1,550 @@
|
||||
// Package sandbox — db_module.go
|
||||
//
|
||||
// v0.29.2 CS1: Structured db module for Starlark extensions.
|
||||
//
|
||||
// Permissions:
|
||||
// db.read → db.query(), db.view(), db.list_tables()
|
||||
// db.write → all of the above + db.insert(), db.update(), db.delete()
|
||||
//
|
||||
// Starlark API:
|
||||
//
|
||||
// rows = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50)
|
||||
// row = db.insert("logs", {"message": "hello"})
|
||||
// ok = db.update("logs", row_id, {"message": "updated"})
|
||||
// ok = db.delete("logs", row_id)
|
||||
// names = db.list_tables()
|
||||
// rows = db.view("users", filters={"id": "abc"})
|
||||
//
|
||||
// All table access is scoped to ext_{package_id}_{table_name}.
|
||||
// Platform views (ext_view_users, ext_view_channels) are accessible
|
||||
// via db.view() and are read-only regardless of permission level.
|
||||
//
|
||||
// Queries are parameterized — no raw SQL is ever accepted from scripts.
|
||||
// Dialect differences (placeholder style) are handled internally.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
)
|
||||
|
||||
// DBModuleConfig holds configuration for a db module instance.
|
||||
type DBModuleConfig struct {
|
||||
PackageID string
|
||||
CanWrite bool // true when db.write permission is granted
|
||||
DB *sql.DB
|
||||
IsPostgres bool
|
||||
}
|
||||
|
||||
// allowedViews is the set of platform views extensions may query via db.view().
|
||||
var allowedViews = map[string]bool{
|
||||
"users": true,
|
||||
"channels": true,
|
||||
}
|
||||
|
||||
// BuildDBModule creates the "db" starlark module for an extension.
|
||||
// The module enforces namespace isolation — all physical table names are
|
||||
// prefixed with ext_{packageID}_. Write operations are guarded by CanWrite.
|
||||
func BuildDBModule(ctx context.Context, cfg DBModuleConfig) *starlarkstruct.Module {
|
||||
fns := starlark.StringDict{
|
||||
"query": starlark.NewBuiltin("db.query", dbQuery(ctx, cfg)),
|
||||
"view": starlark.NewBuiltin("db.view", dbView(ctx, cfg)),
|
||||
"list_tables": starlark.NewBuiltin("db.list_tables", dbListTables(ctx, cfg)),
|
||||
}
|
||||
|
||||
if cfg.CanWrite {
|
||||
fns["insert"] = starlark.NewBuiltin("db.insert", dbInsert(ctx, cfg))
|
||||
fns["update"] = starlark.NewBuiltin("db.update", dbUpdate(ctx, cfg))
|
||||
fns["delete"] = starlark.NewBuiltin("db.delete", dbDelete(ctx, cfg))
|
||||
}
|
||||
|
||||
return MakeModule("db", fns)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
// physicalTable returns the fully-qualified table name for an extension table.
|
||||
func (cfg DBModuleConfig) physicalTable(logicalName string) (string, error) {
|
||||
if logicalName == "" {
|
||||
return "", fmt.Errorf("db: table name must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(logicalName, " \t\n\"';-") {
|
||||
return "", fmt.Errorf("db: invalid table name %q", logicalName)
|
||||
}
|
||||
// Sanitize package ID for use in identifiers (replace - with _)
|
||||
pkgSlug := strings.ReplaceAll(cfg.PackageID, "-", "_")
|
||||
return fmt.Sprintf("ext_%s_%s", pkgSlug, logicalName), nil
|
||||
}
|
||||
|
||||
// ph returns the parameter placeholder for the nth argument (1-based).
|
||||
func (cfg DBModuleConfig) ph(n int) string {
|
||||
if cfg.IsPostgres {
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// generateID produces a random hex ID for new rows.
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// starlarkFiltersToSQL converts a Starlark dict of {col: val} into a
|
||||
// WHERE clause and argument slice. Returns empty string if filters is None.
|
||||
func (cfg DBModuleConfig) starlarkFiltersToSQL(filters starlark.Value, startIdx int) (string, []any, error) {
|
||||
if filters == starlark.None || filters == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
d, ok := filters.(*starlark.Dict)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("db: filters must be a dict, got %s", filters.Type())
|
||||
}
|
||||
if d.Len() == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var args []any
|
||||
idx := startIdx
|
||||
|
||||
for _, item := range d.Items() {
|
||||
col, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("db: filter key must be a string, got %s", item[0].Type())
|
||||
}
|
||||
colStr := string(col)
|
||||
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
||||
return "", nil, fmt.Errorf("db: invalid column name %q", colStr)
|
||||
}
|
||||
|
||||
val, err := starlarkToGoValue(item[1])
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("db: filter value for %q: %w", colStr, err)
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("%s = %s", colStr, cfg.ph(idx)))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
return "WHERE " + strings.Join(parts, " AND "), args, nil
|
||||
}
|
||||
|
||||
// starlarkToGoValue converts a Starlark value to a Go value suitable for SQL.
|
||||
func starlarkToGoValue(v starlark.Value) (any, error) {
|
||||
switch val := v.(type) {
|
||||
case starlark.String:
|
||||
return string(val), nil
|
||||
case starlark.Int:
|
||||
i, ok := val.Int64()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("integer overflow")
|
||||
}
|
||||
return i, nil
|
||||
case starlark.Float:
|
||||
return float64(val), nil
|
||||
case starlark.Bool:
|
||||
return bool(val), nil
|
||||
case starlark.NoneType:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type %s", v.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// rowsToStarlark converts sql.Rows to a Starlark list of dicts.
|
||||
func rowsToStarlark(rows *sql.Rows) (*starlark.List, error) {
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []starlark.Value
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := starlark.NewDict(len(cols))
|
||||
for i, col := range cols {
|
||||
sv, err := goToStarlark(vals[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("column %q: %w", col, err)
|
||||
}
|
||||
if err := d.SetKey(starlark.String(col), sv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return starlark.NewList(result), nil
|
||||
}
|
||||
|
||||
// goToStarlark converts a Go value (from sql.Scan) to a Starlark value.
|
||||
func goToStarlark(v any) (starlark.Value, error) {
|
||||
if v == nil {
|
||||
return starlark.None, nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return starlark.String(val), nil
|
||||
case []byte:
|
||||
return starlark.String(string(val)), nil
|
||||
case int64:
|
||||
return starlark.MakeInt64(val), nil
|
||||
case float64:
|
||||
return starlark.Float(val), nil
|
||||
case bool:
|
||||
return starlark.Bool(val), nil
|
||||
default:
|
||||
// Fallback: stringify
|
||||
return starlark.String(fmt.Sprintf("%v", val)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── Builtins ─────────────────────────────────
|
||||
|
||||
// dbQuery implements db.query(table, filters=None, order=None, limit=100).
|
||||
func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var table string
|
||||
var filters starlark.Value = starlark.None
|
||||
var order starlark.Value = starlark.None
|
||||
var limit starlark.Int = starlark.MakeInt(100)
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"table", &table,
|
||||
"filters?", &filters,
|
||||
"order?", &order,
|
||||
"limit?", &limit,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
physTable, err := cfg.physicalTable(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lim, ok := limit.Int64()
|
||||
if !ok || lim < 1 || lim > 1000 {
|
||||
lim = 100
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s", physTable)
|
||||
if whereClause != "" {
|
||||
query += " " + whereClause
|
||||
}
|
||||
|
||||
if order != starlark.None {
|
||||
col, ok := order.(starlark.String)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("db.query: order must be a string column name")
|
||||
}
|
||||
colStr := string(col)
|
||||
dir := "ASC"
|
||||
if strings.HasPrefix(colStr, "-") {
|
||||
colStr = colStr[1:]
|
||||
dir = "DESC"
|
||||
}
|
||||
if strings.ContainsAny(colStr, " \t\n\"';") {
|
||||
return nil, fmt.Errorf("db.query: invalid order column %q", colStr)
|
||||
}
|
||||
query += fmt.Sprintf(" ORDER BY %s %s", colStr, dir)
|
||||
}
|
||||
|
||||
limitPH := cfg.ph(len(whereArgs) + 1)
|
||||
query += " LIMIT " + limitPH
|
||||
whereArgs = append(whereArgs, lim)
|
||||
|
||||
rows, err := cfg.DB.QueryContext(ctx, query, whereArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return rowsToStarlark(rows)
|
||||
}
|
||||
}
|
||||
|
||||
// dbView implements db.view(view_name, filters=None, limit=100).
|
||||
// Queries ext_view_{view_name} — only allowedViews are permitted.
|
||||
func dbView(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var viewName string
|
||||
var filters starlark.Value = starlark.None
|
||||
var limit starlark.Int = starlark.MakeInt(100)
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"view_name", &viewName,
|
||||
"filters?", &filters,
|
||||
"limit?", &limit,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !allowedViews[viewName] {
|
||||
return nil, fmt.Errorf("db.view: unknown view %q (allowed: users, channels)", viewName)
|
||||
}
|
||||
|
||||
physView := "ext_view_" + viewName
|
||||
|
||||
whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lim, ok := limit.Int64()
|
||||
if !ok || lim < 1 || lim > 1000 {
|
||||
lim = 100
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s", physView)
|
||||
if whereClause != "" {
|
||||
query += " " + whereClause
|
||||
}
|
||||
limitPH := cfg.ph(len(whereArgs) + 1)
|
||||
query += " LIMIT " + limitPH
|
||||
whereArgs = append(whereArgs, lim)
|
||||
|
||||
rows, err := cfg.DB.QueryContext(ctx, query, whereArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.view: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return rowsToStarlark(rows)
|
||||
}
|
||||
}
|
||||
|
||||
// dbListTables implements db.list_tables() → list of logical table names.
|
||||
func dbListTables(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix := "ext_" + strings.ReplaceAll(cfg.PackageID, "-", "_") + "_"
|
||||
var query string
|
||||
var queryArgs []any
|
||||
|
||||
if cfg.IsPostgres {
|
||||
query = `SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name LIKE $1
|
||||
ORDER BY table_name`
|
||||
queryArgs = []any{prefix + "%"}
|
||||
} else {
|
||||
query = `SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name LIKE ?
|
||||
ORDER BY name`
|
||||
queryArgs = []any{prefix + "%"}
|
||||
}
|
||||
|
||||
rows, err := cfg.DB.QueryContext(ctx, query, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.list_tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var names []starlark.Value
|
||||
for rows.Next() {
|
||||
var physName string
|
||||
if err := rows.Scan(&physName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Strip prefix to return logical name
|
||||
logical := strings.TrimPrefix(physName, prefix)
|
||||
names = append(names, starlark.String(logical))
|
||||
}
|
||||
if names == nil {
|
||||
names = []starlark.Value{}
|
||||
}
|
||||
return starlark.NewList(names), nil
|
||||
}
|
||||
}
|
||||
|
||||
// dbInsert implements db.insert(table, row_dict) → inserted row dict with id.
|
||||
func dbInsert(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var table string
|
||||
var rowVal starlark.Value
|
||||
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &table, &rowVal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row, ok := rowVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("db.insert: row must be a dict, got %s", rowVal.Type())
|
||||
}
|
||||
|
||||
physTable, err := cfg.physicalTable(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := generateID()
|
||||
cols := []string{"id"}
|
||||
placeholders := []string{cfg.ph(1)}
|
||||
vals := []any{id}
|
||||
idx := 2
|
||||
|
||||
for _, item := range row.Items() {
|
||||
col, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("db.insert: column key must be a string")
|
||||
}
|
||||
colStr := string(col)
|
||||
if colStr == "id" || colStr == "created_at" {
|
||||
continue // auto-managed
|
||||
}
|
||||
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
||||
return nil, fmt.Errorf("db.insert: invalid column name %q", colStr)
|
||||
}
|
||||
val, err := starlarkToGoValue(item[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.insert: column %q: %w", colStr, err)
|
||||
}
|
||||
cols = append(cols, colStr)
|
||||
placeholders = append(placeholders, cfg.ph(idx))
|
||||
vals = append(vals, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
physTable,
|
||||
strings.Join(cols, ", "),
|
||||
strings.Join(placeholders, ", "),
|
||||
)
|
||||
|
||||
if _, err := cfg.DB.ExecContext(ctx, query, vals...); err != nil {
|
||||
return nil, fmt.Errorf("db.insert: %w", err)
|
||||
}
|
||||
|
||||
// Return the inserted row (re-query by id)
|
||||
selectQ := fmt.Sprintf("SELECT * FROM %s WHERE id = %s", physTable, cfg.ph(1))
|
||||
rows, err := cfg.DB.QueryContext(ctx, selectQ, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.insert: re-query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list, err := rowsToStarlark(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list.Len() == 0 {
|
||||
return starlark.None, nil
|
||||
}
|
||||
return list.Index(0), nil
|
||||
}
|
||||
}
|
||||
|
||||
// dbUpdate implements db.update(table, id, partial_dict) → True.
|
||||
func dbUpdate(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var table, rowID string
|
||||
var patchVal starlark.Value
|
||||
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 3, &table, &rowID, &patchVal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
patch, ok := patchVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("db.update: patch must be a dict, got %s", patchVal.Type())
|
||||
}
|
||||
if patch.Len() == 0 {
|
||||
return starlark.True, nil // nothing to do
|
||||
}
|
||||
|
||||
physTable, err := cfg.physicalTable(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var setParts []string
|
||||
var vals []any
|
||||
idx := 1
|
||||
|
||||
for _, item := range patch.Items() {
|
||||
col, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("db.update: column key must be a string")
|
||||
}
|
||||
colStr := string(col)
|
||||
if colStr == "id" || colStr == "created_at" {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
||||
return nil, fmt.Errorf("db.update: invalid column name %q", colStr)
|
||||
}
|
||||
val, err := starlarkToGoValue(item[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.update: column %q: %w", colStr, err)
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("%s = %s", colStr, cfg.ph(idx)))
|
||||
vals = append(vals, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return starlark.True, nil
|
||||
}
|
||||
|
||||
vals = append(vals, rowID)
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE id = %s",
|
||||
physTable,
|
||||
strings.Join(setParts, ", "),
|
||||
cfg.ph(idx),
|
||||
)
|
||||
|
||||
if _, err := cfg.DB.ExecContext(ctx, query, vals...); err != nil {
|
||||
return nil, fmt.Errorf("db.update: %w", err)
|
||||
}
|
||||
return starlark.True, nil
|
||||
}
|
||||
}
|
||||
|
||||
// dbDelete implements db.delete(table, id) → True.
|
||||
func dbDelete(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var table, rowID string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &table, &rowID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
physTable, err := cfg.physicalTable(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE id = %s", physTable, cfg.ph(1))
|
||||
if _, err := cfg.DB.ExecContext(ctx, query, rowID); err != nil {
|
||||
return nil, fmt.Errorf("db.delete: %w", err)
|
||||
}
|
||||
return starlark.True, nil
|
||||
}
|
||||
}
|
||||
254
server/sandbox/db_module_test.go
Normal file
254
server/sandbox/db_module_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// newTestDB creates an in-memory SQLite database with a test extension table.
|
||||
func newTestDB(t *testing.T) (*sql.DB, DBModuleConfig) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
// Create the extension table the tests will use.
|
||||
// physicalTable("logs") for package "test-ext" → ext_test_ext_logs
|
||||
_, err = db.Exec(`CREATE TABLE ext_test_ext_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
message TEXT,
|
||||
user_id TEXT,
|
||||
count INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create test table: %v", err)
|
||||
}
|
||||
|
||||
cfg := DBModuleConfig{
|
||||
PackageID: "test-ext",
|
||||
CanWrite: true,
|
||||
DB: db,
|
||||
IsPostgres: false,
|
||||
}
|
||||
return db, cfg
|
||||
}
|
||||
|
||||
// execScript runs a Starlark snippet with the db module injected.
|
||||
func execScript(t *testing.T, cfg DBModuleConfig, script string) (*Result, error) {
|
||||
t.Helper()
|
||||
sb := New(DefaultConfig())
|
||||
ctx := context.Background()
|
||||
modules := map[string]starlark.Value{
|
||||
"db": BuildDBModule(ctx, cfg),
|
||||
}
|
||||
return sb.Exec(ctx, "test.star", script, modules)
|
||||
}
|
||||
|
||||
// ── physicalTable ───────────────────────────
|
||||
|
||||
func TestPhysicalTable_ValidName(t *testing.T) {
|
||||
cfg := DBModuleConfig{PackageID: "my-ext"}
|
||||
got, err := cfg.physicalTable("logs")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "ext_my_ext_logs" {
|
||||
t.Errorf("got %q, want ext_my_ext_logs", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalTable_EmptyName(t *testing.T) {
|
||||
cfg := DBModuleConfig{PackageID: "my-ext"}
|
||||
_, err := cfg.physicalTable("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty table name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalTable_InvalidChars(t *testing.T) {
|
||||
cfg := DBModuleConfig{PackageID: "my-ext"}
|
||||
_, err := cfg.physicalTable("bad name")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for table name with space")
|
||||
}
|
||||
_, err = cfg.physicalTable("drop;table")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for table name with semicolon")
|
||||
}
|
||||
}
|
||||
|
||||
// ── db.insert / db.query ────────────────────
|
||||
|
||||
func TestDBInsertAndQuery(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
|
||||
result, err := execScript(t, cfg, `
|
||||
row = db.insert("logs", {"message": "hello", "user_id": "u1"})
|
||||
rows = db.query("logs")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
|
||||
rowsVal, ok := result.Globals["rows"]
|
||||
if !ok {
|
||||
t.Fatal("rows not in globals")
|
||||
}
|
||||
if _, ok := rowsVal.(*starlark.List); !ok {
|
||||
t.Fatalf("rows is %T, want *starlark.List", rowsVal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryFilters(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
|
||||
// Pre-populate with two rows
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'one', 'u1')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'two', 'u2')`)
|
||||
|
||||
result, err := execScript(t, cfg, `
|
||||
rows = db.query("logs", filters={"user_id": "u1"})
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "one") {
|
||||
t.Errorf("expected row with message=one, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "two") {
|
||||
t.Errorf("unexpected row with message=two: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryLimit(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message) VALUES (?, ?)`,
|
||||
generateID(), "msg")
|
||||
}
|
||||
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", limit=2)`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
// String representation of a 2-element list has exactly 2 entries
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
// Count occurrences of "id" key — each row has one
|
||||
count := strings.Count(rowsStr, `"id"`)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 rows with limit=2, got repr: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// ── db.update ───────────────────────────────
|
||||
|
||||
func TestDBUpdate(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message) VALUES ('r1', 'original')`)
|
||||
|
||||
_, err := execScript(t, cfg, `db.update("logs", "r1", {"message": "updated"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
|
||||
var msg string
|
||||
db.QueryRow(`SELECT message FROM ext_test_ext_logs WHERE id = 'r1'`).Scan(&msg)
|
||||
if msg != "updated" {
|
||||
t.Errorf("expected message=updated, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ── db.delete ───────────────────────────────
|
||||
|
||||
func TestDBDelete(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message) VALUES ('r1', 'to-delete')`)
|
||||
|
||||
_, err := execScript(t, cfg, `db.delete("logs", "r1")`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM ext_test_ext_logs WHERE id = 'r1'`).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected row deleted, count = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── write guard ─────────────────────────────
|
||||
|
||||
func TestDBReadOnlyModuleNoWriteFunctions(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
cfg.CanWrite = false
|
||||
|
||||
mod := BuildDBModule(context.Background(), cfg)
|
||||
modStr := mod.String()
|
||||
|
||||
// insert/update/delete should not be present in a read-only module
|
||||
for _, fn := range []string{"insert", "update", "delete"} {
|
||||
if strings.Contains(modStr, fn) {
|
||||
t.Errorf("read-only module exposes write function %q", fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBWriteFunctionBlockedByReadOnly(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
cfg.CanWrite = false
|
||||
|
||||
_, err := execScript(t, cfg, `db.insert("logs", {"message": "x"})`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error calling insert on read-only module")
|
||||
}
|
||||
}
|
||||
|
||||
// ── view allowlist ───────────────────────────
|
||||
|
||||
func TestDBViewRejectsUnknownView(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
_, err := execScript(t, cfg, `db.view("secrets")`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown view")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown view") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── namespace isolation ──────────────────────
|
||||
|
||||
func TestDBQueryRejectsOtherExtensionTable(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
// Attempt to query a table that belongs to a different extension
|
||||
_, err := execScript(t, cfg, `db.query("../../etc/passwd")`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// ── list_tables ─────────────────────────────
|
||||
|
||||
func TestDBListTables(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
result, err := execScript(t, cfg, `names = db.list_tables()`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
namesStr := result.Globals["names"].String()
|
||||
// The test table ext_test_ext_logs should appear as "logs"
|
||||
if !strings.Contains(namesStr, "logs") {
|
||||
t.Errorf("expected 'logs' in list_tables output, got: %s", namesStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
// v0.29.1 CS2: Adds RunContext for per-invocation state (user_id),
|
||||
// vault for provider key decryption, and provider.complete module.
|
||||
//
|
||||
// v0.29.2 CS1: Adds db *sql.DB for the db module. SetDB() wires it
|
||||
// at startup. db.read grants query/view/list_tables; db.write adds
|
||||
// insert/update/delete. If both are granted, db.write wins (superset).
|
||||
//
|
||||
// The runner is the bridge between the package/permission system
|
||||
// and the sandboxed Starlark interpreter.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
@@ -38,10 +43,12 @@ type RunContext struct {
|
||||
|
||||
// Runner executes Starlark package scripts with permission-gated modules.
|
||||
type Runner struct {
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -62,6 +69,14 @@ func (r *Runner) SetProviderResolver(pr ProviderResolver) {
|
||||
r.resolver = pr
|
||||
}
|
||||
|
||||
// SetDB attaches the raw database connection for the db module.
|
||||
// isPostgres controls whether $N or ? placeholders are used.
|
||||
// Call this at startup after database.Connect().
|
||||
func (r *Runner) SetDB(db *sql.DB, isPostgres bool) {
|
||||
r.db = db
|
||||
r.dbPostgres = isPostgres
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from its manifest and executes it
|
||||
// with modules gated by the package's granted permissions.
|
||||
//
|
||||
@@ -98,9 +113,9 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
|
||||
// CallEntryPoint executes a package script and calls a named function.
|
||||
// This is the standard pattern for event-driven extensions:
|
||||
// 1. Exec the script (defines functions)
|
||||
// 2. Find the named entry point in globals
|
||||
// 3. Call it with the provided arguments
|
||||
// 1. Exec the script (defines functions)
|
||||
// 2. Find the named entry point in globals
|
||||
// 3. Call it with the provided arguments
|
||||
//
|
||||
// The RunContext carries per-invocation state. Pass nil if not needed.
|
||||
//
|
||||
@@ -141,6 +156,9 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
||||
|
||||
modules := make(map[string]starlark.Value)
|
||||
|
||||
// Track db permission level: 0=none, 1=read, 2=write
|
||||
dbLevel := 0
|
||||
|
||||
for _, perm := range granted {
|
||||
switch perm {
|
||||
case models.ExtPermSecretsRead:
|
||||
@@ -163,11 +181,25 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
||||
}
|
||||
}
|
||||
|
||||
// Future permissions:
|
||||
// case models.ExtPermDBRead, models.ExtPermDBWrite:
|
||||
// modules["db"] = BuildDBModule(...)
|
||||
case models.ExtPermDBRead:
|
||||
if dbLevel < 1 {
|
||||
dbLevel = 1
|
||||
}
|
||||
|
||||
case models.ExtPermDBWrite:
|
||||
dbLevel = 2
|
||||
}
|
||||
}
|
||||
|
||||
// Wire db module at the highest granted level.
|
||||
if dbLevel > 0 && r.db != nil {
|
||||
modules["db"] = BuildDBModule(ctx, DBModuleConfig{
|
||||
PackageID: packageID,
|
||||
CanWrite: dbLevel == 2,
|
||||
DB: r.db,
|
||||
IsPostgres: r.dbPostgres,
|
||||
})
|
||||
}
|
||||
|
||||
return modules, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user