implement KV for policy engine
jump to
@@ -93,7 +93,7 @@ t.Fatal("expected to find 'removed' in output. Found none")
} // Try removing user again (failure) - output, err = executeCommand(c.rootCmd(), "user", "remove", "user1") + _, err = executeCommand(c.rootCmd(), "user", "remove", "user1") if err == nil { t.Errorf("expected error when deleting non-existing user, got none") }
@@ -0,0 +1,78 @@
+package db + +import ( + "context" + "database/sql" + "errors" +) + +func (db *DB) KVSet(ctx context.Context, key string, value any) error { + // Upsert into SQLite + query := ` + INSERT INTO kv_store (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP;` + + _, err := db.write.ExecContext(ctx, query, key, value) + return err +} + +func (db *DB) KVGet(ctx context.Context, key string) (any, error) { + var rawVal any + err := db.read.QueryRowContext(ctx, "SELECT value FROM kv_store WHERE key = ?", key).Scan(&rawVal) + return rawVal, err +} + +func (db *DB) KVInc(ctx context.Context, key string, delta int64) (int64, error) { + tx, err := db.GetWriteTx(ctx) + if err != nil { + return 0, err + } + + var val any + err = tx.QueryRowContext(ctx, "SELECT value FROM kv_store WHERE key = ?", key).Scan(&val) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + _ = tx.Rollback() + return 0, err + } + + if errors.Is(err, sql.ErrNoRows) { + _, err = tx.ExecContext(ctx, "INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", key, delta) + if err != nil { + _ = tx.Rollback() + return 0, err + } + return delta, tx.Commit() + } + + if _, ok := val.(int64); !ok { + _ = tx.Rollback() + return 0, errors.New("KVInc: value is not an integer") + } + + query := ` +INSERT INTO kv_store (key, value, updated_at) +VALUES (?, ?, CURRENT_TIMESTAMP) +ON CONFLICT(key) DO UPDATE SET + value = CAST(kv_store.value AS INTEGER) + excluded.value, + updated_at = CURRENT_TIMESTAMP +RETURNING value;` + + var newCount int64 + err = tx.QueryRowContext(ctx, query, key, delta).Scan(&newCount) + if err != nil { + _ = tx.Rollback() + return 0, err + } + + return newCount, tx.Commit() +} + +func (db *DB) KVPop(ctx context.Context, key string) (any, error) { + var rawVal any + query := `DELETE FROM kv_store WHERE key = ? RETURNING kv_store.value;` + err := db.write.QueryRowContext(ctx, query, key).Scan(&rawVal) + return rawVal, err +}
@@ -0,0 +1,126 @@
+package db + +import ( + "path" + "sync" + "testing" +) + +// TestKVInc tests that the KVInc function behaves correctly under concurrent access +func TestKVInc(t *testing.T) { + posternDB, err := OpenDB(&Config{ + DBPath: path.Join(t.TempDir(), "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + defer posternDB.Close() + + key := "test-concurrency-key" + if err := posternDB.KVSet(t.Context(), key, 0); err != nil { + t.Fatalf("KVSet setup failed: %v", err) + } + + const numGoroutines = 50 + const incsPerGoroutine = 200 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for range numGoroutines { + go func(db *DB) { + defer wg.Done() + for range incsPerGoroutine { + _, err := db.KVInc(t.Context(), key, 1) + if err != nil { + t.Errorf("KVInc failed during execution: %v", err) + return + } + } + }(posternDB) + } + + wg.Wait() + + // Retrieve the final count + val, err := posternDB.KVGet(t.Context(), key) + if err != nil { + t.Fatalf("KVGet failed to fetch final value: %v", err) + } + + got, ok := val.(int64) + if !ok { + t.Fatalf("KVGet returned unexpected type: %T, expected int", val) + } + + // Catch race conditions + expected := int64(numGoroutines * incsPerGoroutine) + if got != expected { + t.Errorf("Race condition detected! Expected final value %d, got %d", expected, got) + } + + // Check return value of next increment + res, err := posternDB.KVInc(t.Context(), key, 1) + if err != nil { + t.Errorf("KVInc failed during execution: %v", err) + return + } + + if res != expected+1 { + t.Errorf("Incrementation mismatch! Expected final value %d, got %d", expected+1, res) + } +} + +// TestKVIncString tests that the KVInc function does not increment invalid values +func TestKVIncString(t *testing.T) { + posternDB, err := OpenDB(&Config{ + DBPath: path.Join(t.TempDir(), "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + defer posternDB.Close() + + key := "test-key" + if err := posternDB.KVSet(t.Context(), key, "value"); err != nil { + t.Fatalf("KVSet setup failed: %v", err) + } + + _, err = posternDB.KVInc(t.Context(), key, 1) + if err == nil { + t.Fatalf("KVInc should have failed during execution due to attempt incrementing a non-integer value") + } +} + +// TestKVDelete tests the KVDelete function +func TestKVDelete(t *testing.T) { + posternDB, err := OpenDB(&Config{ + DBPath: path.Join(t.TempDir(), "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + defer posternDB.Close() + + key := "test-key" + var testVal int64 = 42 + if err := posternDB.KVSet(t.Context(), key, testVal); err != nil { + t.Fatalf("KVSet setup failed: %v", err) + } + + val, err := posternDB.KVPop(t.Context(), key) + if err != nil { + t.Fatalf("KVGet failed to fetch final value: %v", err) + } + if val != testVal { + t.Errorf("Expected value %d, got %d", testVal, val) + } + + _, err = posternDB.KVPop(t.Context(), key) + if err == nil { + t.Fatalf("KVDelete should have failed during execution due to missing key") + } +}
@@ -11,6 +11,8 @@ "net/mail"
"time" "postern/internal/model" + + "github.com/emersion/go-imap/v2" ) func (db *DB) GetAllMailboxMessages(ctx context.Context, userID, mailboxID int) ([]model.Message, error) {@@ -498,3 +500,31 @@ return false, fmt.Errorf("error checking uid existence: %w", err)
} return exists, nil } + +func (db *DB) GetLastUID(ctx context.Context, userID, mailboxID int) (imap.UID, error) { + var uid imap.UID + err := db.read.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(mm.uid), 0) + FROM mailbox_messages mm + JOIN mailboxes mb ON mb.id = mm.mailbox_id + WHERE mm.mailbox_id = ? + AND mb.user_id = ? + AND mm.expunged_modseq IS NULL + `, mailboxID, userID).Scan(&uid) + return uid, err +} + +// nextUIDValidity is db roll-back save. Even with a played-back backup, the counter will be increasing. +// It will also be increasing if it is called more than once per second. +func nextUIDValidity(tx *sql.Tx) (uint32, error) { + var v int64 + err := tx.QueryRow(` + UPDATE uidvalidity_counter + SET counter = MAX(counter + 1, CAST(strftime('%s','now') AS INTEGER)) + RETURNING counter + `).Scan(&v) + if err != nil { + return 0, err + } + return uint32(v), nil +}
@@ -7,9 +7,10 @@ "errors"
"fmt" "log/slog" - "github.com/emersion/go-imap/v2" _ "modernc.org/sqlite" ) + +var schemaVersion = len(schemaChanges()) const pragmas = ` PRAGMA journal_mode = WAL;@@ -19,7 +20,9 @@ PRAGMA busy_timeout = 5000;
PRAGMA temp_store = MEMORY; ` -const schema = ` +func schemaChanges() []string { + + schema := []string{` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY,@@ -146,7 +149,22 @@
INSERT INTO uidvalidity_counter (id, counter) VALUES (1, 0) ON CONFLICT(id) DO NOTHING; -` + +CREATE TABLE IF NOT EXISTS schema_version ( + version INT NOT NULL +) STRICT; +`} + + schema = append(schema, ` +CREATE TABLE kv_store ( + key TEXT PRIMARY KEY, + value ANY, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +) WITHOUT ROWID; -- WITHOUT ROWID optimizes key-value lookups +`) + + return schema +} func (db *DB) applyPragmas(ctx context.Context) error { for _, conn := range []*sql.DB{db.write, db.read} {@@ -158,9 +176,42 @@ return nil
} func (db *DB) applySchema(ctx context.Context) error { - if _, err := db.write.ExecContext(ctx, schema); err != nil { - return fmt.Errorf("applying schema: %w", err) + var currentVersion int + // Ignore the error on purpose in case schema_version does not exist _yet_. + // The very first schema can be applied idempotently. + _ = db.read.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(¤tVersion) + + for version := currentVersion; version < schemaVersion; version++ { + newVersion := version + 1 + + slog.InfoContext(ctx, "updating schema version", "from", currentVersion, "to", newVersion) + + tx, err := db.GetWriteTx(ctx) + if err != nil { + return errors.Join(fmt.Errorf("migration %d transaction not started", newVersion), err) + } + + if _, err := tx.ExecContext(ctx, schemaChanges()[version]); err != nil { + errRollback := tx.Rollback() + return errors.Join(fmt.Errorf("migration %d rolled back", newVersion), err, errRollback) + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM schema_version`); err != nil { + errRollback := tx.Rollback() + return errors.Join(fmt.Errorf("migration %d rolled back", newVersion), err, errRollback) + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (?)`, newVersion); err != nil { + errRollback := tx.Rollback() + return errors.Join(fmt.Errorf("migration %d rolled back", newVersion), err, errRollback) + } + + if err := tx.Commit(); err != nil { + return errors.Join(fmt.Errorf("migration %d not commited", newVersion), err) + } } + + slog.InfoContext(ctx, "applying schema", "version", schemaVersion) return nil }@@ -171,34 +222,6 @@ if werr != nil {
return werr } return rerr -} - -func (db *DB) GetLastUID(ctx context.Context, userID, mailboxID int) (imap.UID, error) { - var uid imap.UID - err := db.read.QueryRowContext(ctx, ` - SELECT COALESCE(MAX(mm.uid), 0) - FROM mailbox_messages mm - JOIN mailboxes mb ON mb.id = mm.mailbox_id - WHERE mm.mailbox_id = ? - AND mb.user_id = ? - AND mm.expunged_modseq IS NULL - `, mailboxID, userID).Scan(&uid) - return uid, err -} - -// nextUIDValidity is db roll-back save. Even with a played-back backup, the counter will be increasing. -// It will also be increasing if it is called more than once per second. -func nextUIDValidity(tx *sql.Tx) (uint32, error) { - var v int64 - err := tx.QueryRow(` - UPDATE uidvalidity_counter - SET counter = MAX(counter + 1, CAST(strftime('%s','now') AS INTEGER)) - RETURNING counter - `).Scan(&v) - if err != nil { - return 0, err - } - return uint32(v), nil } func txRollback(tx *sql.Tx) {
@@ -82,7 +82,6 @@
logger.Info("logged in", "user_id", u.ID) m.user = u return nil - } // Select and Examine are identical besides read-only (https://www.ietf.org/rfc/rfc9051.html#name-examine-command)
@@ -10,10 +10,4 @@ Code: 451,
EnhancedCode: smtpserver.EnhancedCode{4, 3, 0}, Message: "Internal Server Error", } - - errSTARTTLSrequired = &smtpserver.SMTPError{ - Code: 421, - EnhancedCode: smtpserver.EnhancedCode{4, 7, 0}, - Message: "STARTTLS required", - } )
@@ -108,7 +108,6 @@ deliverer MessageDeliverer
authenticatedUser *model.SMTPAuth toAddress string fromAddress string - helo string destinationUser model.User sessionID string policyEngine *policy.Engine@@ -260,7 +259,7 @@
var buffer bytes.Buffer // Prepend headers - buffer.WriteString(fmt.Sprintf("Return-Path: %s\r\n", s.fromAddress)) + _, _ = fmt.Fprintf(&buffer, "Return-Path: %s\r\n", s.fromAddress) myHostname, err := os.Hostname() if err != nil { myHostname = "postern.local"@@ -268,8 +267,8 @@ }
with := "ESMTPSA" // we only allow encrypted (S) and authenticated (A) connections for inbound receivedTimestampLayout := "Mon, 02 Jan 2006 15:04:05 -0700 (UTC)" receivedTimestamp := time.Now().UTC().Format(receivedTimestampLayout) - buffer.WriteString(fmt.Sprintf("Received: from %s (%s) by %s (Postern p25.dev) with %s id %s for <%s>; %s\r\n", - s.conn.Hostname(), s.conn.Conn().RemoteAddr().String(), myHostname, with, s.sessionID, s.toAddress, receivedTimestamp)) + _, _ = fmt.Fprintf(&buffer, "Received: from %s (%s) by %s (Postern p25.dev) with %s id %s for <%s>; %s\r\n", + s.conn.Hostname(), s.conn.Conn().RemoteAddr().String(), myHostname, with, s.sessionID, s.toAddress, receivedTimestamp) _, err = io.Copy(&buffer, r) if err != nil {
@@ -0,0 +1,51 @@
+package policy + +import ( + "errors" + "net/mail" + + "go.starlark.net/starlark" +) + +type Address struct { + Address, Name, Raw string +} + +func (a Address) String() string { return a.Raw } +func (a Address) Type() string { return "Address" } +func (a Address) Freeze() {} +func (a Address) Truth() starlark.Bool { return true } +func (a Address) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (a Address) Attr(name string) (starlark.Value, error) { + switch name { + case "address": + return starlark.String(a.Address), nil + case "name": + return starlark.String(a.Name), nil + case "raw": + return starlark.String(a.Raw), nil + } + return nil, nil +} + +func (a Address) AttrNames() []string { + return []string{"address", "name", "raw"} +} + +func ParseAddress(email string) (Address, error) { + a, err := mail.ParseAddress(email) + if err != nil { + return Address{}, err + } + + addr := Address{ + Raw: email, + Address: a.Address, + Name: a.Name, + } + + return addr, nil +}
@@ -0,0 +1,55 @@
+package policy + +import ( + "errors" + "net" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" +) + +type Conn struct { + RemoteAddr starlark.String + ClientIP starlark.String + Meta *starlarkstruct.Struct +} + +func (c Conn) String() string { return "<Conn>" } +func (c Conn) Type() string { return "Conn" } +func (c Conn) Freeze() { c.Meta.Freeze() } +func (c Conn) Truth() starlark.Bool { return true } +func (c Conn) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (c Conn) Attr(name string) (starlark.Value, error) { + switch name { + case "remote_addr": + return c.RemoteAddr, nil + case "client_ip": + return c.ClientIP, nil + case "meta": + return c.Meta, nil + } + return nil, nil +} + +func (c Conn) AttrNames() []string { + return []string{"remote_addr", "client_ip", "meta"} +} + +func ConnToStarlark(conn net.Conn) Conn { + remoteAddr := conn.RemoteAddr().String() + var clientIP string + if tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr); ok { + clientIP = tcpAddr.IP.String() + } else { + clientIP = remoteAddr + } + + return Conn{ + RemoteAddr: starlark.String(remoteAddr), + ClientIP: starlark.String(clientIP), + Meta: starlarkstruct.FromStringDict(starlark.None, starlark.StringDict{}), + } +}
@@ -0,0 +1,181 @@
+package policy + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "postern/internal/db" + + "go.starlark.net/starlark" +) + +type KVModule struct { + db *db.DB +} + +func NewKVModule(db *db.DB) *KVModule { + return &KVModule{ + db: db, + } +} + +func (k *KVModule) set(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + ctx, ok := thread.Local("context").(context.Context) + if !ok || ctx == nil { + ctx = context.Background() + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + var keyStr string + var val starlark.Value + + if err := starlark.UnpackArgs("kv.set", args, kwargs, "key", &keyStr, "value", &val); err != nil { + return nil, err + } + + // Convert Starlark value to Go/SQLite compatible interface + var goVal any + switch v := val.(type) { + case starlark.String: + goVal = string(v) + case starlark.Int: + i, _ := v.Int64() + goVal = i + case starlark.Bool: + goVal = bool(v) + case starlark.Bytes: + goVal = []byte(v) + default: + return nil, fmt.Errorf("kv.set: unsupported value type %s", val.Type()) + } + + err := k.db.KVSet(ctx, keyStr, goVal) + if err != nil { + return nil, fmt.Errorf("kv.set: %w", err) + } + + return starlark.None, nil +} + +func (k *KVModule) get(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + ctx, ok := thread.Local("context").(context.Context) + if !ok || ctx == nil { + ctx = context.Background() + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + var keyStr string + var defaultVal starlark.Value = starlark.None + + if err := starlark.UnpackArgs("kv.get", args, kwargs, "key", &keyStr, "default?", &defaultVal); err != nil { + return nil, err + } + + rawVal, err := k.db.KVGet(ctx, keyStr) + if errors.Is(err, sql.ErrNoRows) { + return defaultVal, nil + } else if err != nil { + return nil, err + } + + return rawToStarlark(rawVal) +} + +func (k *KVModule) inc(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + ctx, ok := thread.Local("context").(context.Context) + if !ok || ctx == nil { + ctx = context.Background() + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + var keyStr string + var delta int64 = 1 + + if err := starlark.UnpackArgs("kv.inc", args, kwargs, "key", &keyStr, "delta?", &delta); err != nil { + return nil, err + } + + updatedVal, err := k.db.KVInc(ctx, keyStr, delta) + if err != nil { + return nil, err + } + + return starlark.MakeInt64(updatedVal), nil +} + +func (k *KVModule) pop(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + ctx, ok := thread.Local("context").(context.Context) + if !ok || ctx == nil { + ctx = context.Background() + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + var keyStr string + if err := starlark.UnpackArgs("kv.pop", args, kwargs, "key", &keyStr); err != nil { + return nil, err + } + + rawVal, err := k.db.KVPop(ctx, keyStr) + if errors.Is(err, sql.ErrNoRows) { + return starlark.None, nil + } else if err != nil { + return nil, err + } + + return rawToStarlark(rawVal) +} + +func (k *KVModule) String() string { return "<module kv>" } +func (k *KVModule) Type() string { return "module" } +func (k *KVModule) Freeze() {} +func (k *KVModule) Truth() starlark.Bool { return true } +func (k *KVModule) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (k *KVModule) Attr(name string) (starlark.Value, error) { + switch name { + case "set": + return starlark.NewBuiltin("set", k.set), nil + case "get": + return starlark.NewBuiltin("get", k.get), nil + case "inc": + return starlark.NewBuiltin("inc", k.inc), nil + case "pop": + return starlark.NewBuiltin("pop", k.pop), nil + } + return nil, nil +} + +func (k *KVModule) AttrNames() []string { + return []string{"get", "set", "inc", "pop"} +} + +func rawToStarlark(rawVal any) (starlark.Value, error) { + switch v := rawVal.(type) { + case int64: + return starlark.MakeInt64(v), nil + case string: + return starlark.String(v), nil + case []byte: + return starlark.Bytes(v), nil + case bool: + return starlark.Bool(v), nil + default: + return starlark.None, errors.New("kv.get: unsupported value from database") + } +}
@@ -0,0 +1,153 @@
+package policy + +import ( + "os" + "path" + "testing" + + "postern/internal/db" + + "go.starlark.net/starlark" +) + +func TestKVModule(t *testing.T) { + posternDB, err := db.OpenDB(&db.Config{ + DBPath: path.Join(t.TempDir(), "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + defer posternDB.Close() + + policy := ` +def init(): + return None + +def test_kv_set(k, v): + kv.set(k, v) + +def test_kv_get(k): + return kv.get(k) + +def test_kv_get_default(k, d): + return kv.get(k, default=d) + +def test_kv_inc_delta(k, d): + return kv.inc(k, delta=d) + +def test_kv_inc(k): + return kv.inc(k) + +def test_kv_pop(k): + return kv.pop(k) +` + + policyFile := path.Join(t.TempDir(), "policy.star") + err = os.WriteFile(policyFile, []byte(policy), 0644) + if err != nil { + t.Fatal(err) + } + + e, err := NewEngine(policyFile, posternDB) + if err != nil { + t.Fatal(err) + } + + // KV Set initial value + hookVal, ok := e.globals["test_kv_set"] + if !ok { + t.Fatal("test_kv_set not found in engine globals") + } + + k, v := starlark.String("key"), starlark.MakeInt(1) + _, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k, v}, nil) + if err != nil { + t.Fatal(err) + } + + // KV Set unsupported type + k, unsupported := starlark.String("key"), &starlark.Dict{} + _, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k, unsupported}, nil) + if err == nil { + t.Fatal("expected unsupported type to throw an error") + } + + // KV get initial value + hookVal, ok = e.globals["test_kv_get"] + if !ok { + t.Fatal("test_kv_get not found in engine globals") + } + + res, err := starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + if err != nil { + t.Fatal(err) + } + if res != v { + t.Fatalf("got %s, want %s", res, v) + } + + // Get default value for non-existing key + hookVal, ok = e.globals["test_kv_get_default"] + if !ok { + t.Fatal("test_kv_get_default not found in engine globals") + } + res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{starlark.String("not-exist"), starlark.String("default")}, nil) + if err != nil { + t.Fatal(err) + } + if want := starlark.String("default"); res != want { + t.Fatalf("got %s, want %s", res, want) + } + + // KV increment initial value by default delta=1 + hookVal, ok = e.globals["test_kv_inc"] + if !ok { + t.Fatal("test_kv_inc not found in engine globals") + } + + res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + if err != nil { + t.Fatal(err) + } + if want := starlark.MakeInt(2); res != want { + t.Fatalf("got %s, want %s", res, want) + } + + // KV increment by delta=2 + hookVal, ok = e.globals["test_kv_inc_delta"] + if !ok { + t.Fatal("test_kv_inc not found in engine globals") + } + + res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k, starlark.MakeInt(2)}, nil) + if err != nil { + t.Fatal(err) + } + if want := starlark.MakeInt(4); res != want { + t.Fatalf("got %s, want %s", res, want) + } + + // KV pop + hookVal, ok = e.globals["test_kv_pop"] + if !ok { + t.Fatal("test_kv_pop not found in engine globals") + } + + res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + if err != nil { + t.Fatal(err) + } + if want := starlark.MakeInt(4); res != want { + t.Fatalf("got %s, want %s", res, want) + } + + // KV pop non-existing + res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + if err != nil { + t.Fatal(err) + } + if res != starlark.None { + t.Fatalf("got %s, want %s", res, starlark.None) + } +}
@@ -0,0 +1,66 @@
+package policy + +import ( + "errors" + "net/netip" + + "go.starlark.net/starlark" +) + +// MDA implements starlark.Value and starlark.HasAttrs +// so it can be exposed as the `mda` global object in the script. +type MDA struct { + parsedListeners []ListenerConfig +} + +func (m *MDA) String() string { return "<module mda>" } +func (m *MDA) Type() string { return "module" } +func (m *MDA) Freeze() {} +func (m *MDA) Truth() starlark.Bool { return true } +func (m *MDA) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (m *MDA) Attr(name string) (starlark.Value, error) { + switch name { + case "listen": + return starlark.NewBuiltin("listen", m.mdaListen), nil + } + return nil, nil +} +func (m *MDA) AttrNames() []string { + return []string{"listen"} +} + +// mdaListen handles mda.listen(address="...", role="...") +func (m *MDA) mdaListen(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var address, role, tlsCertFile, tlsKeyFile string + + // starlark.UnpackArgs maps Starlark arguments to Go variables safely + err := starlark.UnpackArgs( + b.Name(), + args, + kwargs, + "address", &address, + "role", &role, + "tls_cert_file", &tlsCertFile, + "tls_key_file", &tlsKeyFile) + if err != nil { + return nil, err + } + + addrPort, err := netip.ParseAddrPort(address) + if err != nil { + return nil, err + } + + // Save to our Go state + m.parsedListeners = append(m.parsedListeners, ListenerConfig{ + Address: addrPort, + Role: role, + TLSCertFile: tlsCertFile, + TLSKeyFile: tlsKeyFile, + }) + + return starlark.None, nil +}
@@ -0,0 +1,60 @@
+package policy + +import ( + "errors" + "net/mail" + + "go.starlark.net/starlark" +) + +type MessageContext struct { + HeaderFrom Address + HeaderTo Address + Subject string + Headers mail.Header + Size int64 +} + +func (m MessageContext) String() string { return "<MessageContext>" } +func (m MessageContext) Type() string { return "MessageContext" } +func (m MessageContext) Freeze() { + m.HeaderFrom.Freeze() + m.HeaderTo.Freeze() +} +func (m MessageContext) Truth() starlark.Bool { return true } +func (m MessageContext) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (m MessageContext) Attr(name string) (starlark.Value, error) { + switch name { + case "header_from": + return m.HeaderFrom, nil + case "header_to": + return m.HeaderTo, nil + case "subject": + return starlark.String(m.Subject), nil + case "size": + return starlark.MakeInt64(m.Size), nil + } + return nil, nil +} + +func (m MessageContext) AttrNames() []string { + return []string{"header_from", "header_to", "subject", "size"} +} + +// MessageContextFromEnvelope builds a policy.MessageContext from the envelope +// fields of a stored message. The header_from address is parsed into an +// Address; if parsing fails the raw string is preserved. +func MessageContextFromEnvelope(from, subject string, size int64) MessageContext { + fromAddr, err := ParseAddress(from) + if err != nil || from == "" { + fromAddr = Address{Raw: from, Address: from} + } + return MessageContext{ + HeaderFrom: fromAddr, + Subject: subject, + Size: size, + } +}
@@ -1,10 +1,9 @@
package policy import ( + "context" "fmt" "log" - "net" - "net/mail" "net/netip" "os"@@ -36,168 +35,6 @@ TLSCertFile string
TLSKeyFile string } -type Address struct { - Address, Name, Raw string -} - -func (a Address) String() string { return a.Raw } -func (a Address) Type() string { return "Address" } -func (a Address) Freeze() {} -func (a Address) Truth() starlark.Bool { return true } -func (a Address) Hash() (uint32, error) { return 0, nil } - -func (a Address) Attr(name string) (starlark.Value, error) { - switch name { - case "address": - return starlark.String(a.Address), nil - case "name": - return starlark.String(a.Name), nil - case "raw": - return starlark.String(a.Raw), nil - } - return nil, nil -} - -func (a Address) AttrNames() []string { - return []string{"address", "name", "raw"} -} - -type MessageContext struct { - HeaderFrom Address - HeaderTo Address - Subject string - Headers mail.Header - Size int64 -} - -func (m MessageContext) String() string { return "<MessageContext>" } -func (m MessageContext) Type() string { return "MessageContext" } -func (m MessageContext) Freeze() { - m.HeaderFrom.Freeze() - m.HeaderTo.Freeze() -} -func (m MessageContext) Truth() starlark.Bool { return true } -func (m MessageContext) Hash() (uint32, error) { return 0, nil } - -func (m MessageContext) Attr(name string) (starlark.Value, error) { - switch name { - case "header_from": - return m.HeaderFrom, nil - case "header_to": - return m.HeaderTo, nil - case "subject": - return starlark.String(m.Subject), nil - case "size": - return starlark.MakeInt64(m.Size), nil - } - return nil, nil -} - -func (m MessageContext) AttrNames() []string { - return []string{"header_from", "subject", "size"} -} - -// MDA implements starlark.Value and starlark.HasAttrs -// so it can be exposed as the `mda` global object in the script. -type MDA struct { - parsedListeners []ListenerConfig -} - -func (m *MDA) String() string { return "<module mda>" } -func (m *MDA) Type() string { return "module" } -func (m *MDA) Freeze() {} -func (m *MDA) Truth() starlark.Bool { return true } -func (m *MDA) Hash() (uint32, error) { return 0, nil } - -// Attr resolves mda.listen, mda.accept, mda.reject, mda.deliver_to, and mda.quarantine when called in Starlark -func (m *MDA) Attr(name string) (starlark.Value, error) { - switch name { - case "listen": - return starlark.NewBuiltin("listen", m.mdaListen), nil - } - return nil, nil -} -func (m *MDA) AttrNames() []string { - return []string{"listen"} -} - -type Conn struct { - RemoteAddr starlark.String - ClientIP starlark.String - Meta *starlarkstruct.Struct -} - -func (c Conn) String() string { return "<Conn>" } -func (c Conn) Type() string { return "Conn" } -func (c Conn) Freeze() { c.Meta.Freeze() } -func (c Conn) Truth() starlark.Bool { return true } -func (c Conn) Hash() (uint32, error) { return 0, nil } - -func (c Conn) Attr(name string) (starlark.Value, error) { - switch name { - case "remote_addr": - return c.RemoteAddr, nil - case "client_ip": - return c.ClientIP, nil - case "meta": - return c.Meta, nil - } - return nil, nil -} - -func (c Conn) AttrNames() []string { - return []string{"remote_addr", "client_ip", "meta"} -} - -func ConnToStarlark(conn net.Conn) Conn { - remoteAddr := conn.RemoteAddr().String() - var clientIP string - if tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr); ok { - clientIP = tcpAddr.IP.String() - } else { - clientIP = remoteAddr - } - - return Conn{ - RemoteAddr: starlark.String(remoteAddr), - ClientIP: starlark.String(clientIP), - Meta: starlarkstruct.FromStringDict(starlark.None, starlark.StringDict{}), - } -} - -// mdaListen handles mda.listen(address="...", role="...") -func (m *MDA) mdaListen(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var address, role, tlsCertFile, tlsKeyFile string - - // starlark.UnpackArgs maps Starlark arguments to Go variables safely - err := starlark.UnpackArgs( - b.Name(), - args, - kwargs, - "address", &address, - "role", &role, - "tls_cert_file", &tlsCertFile, - "tls_key_file", &tlsKeyFile) - if err != nil { - return nil, err - } - - addrPort, err := netip.ParseAddrPort(address) - if err != nil { - return nil, err - } - - // Save to our Go state - m.parsedListeners = append(m.parsedListeners, ListenerConfig{ - Address: addrPort, - Role: role, - TLSCertFile: tlsCertFile, - TLSKeyFile: tlsKeyFile, - }) - - return starlark.None, nil -} - func NewEngine(policyFile string, database *db.DB) (Engine, error) { script, err := os.ReadFile(policyFile) if err != nil {@@ -214,12 +51,14 @@ }
// Create the users module usersModule := NewUsersModule(database) + kvModule := NewKVModule(database) // Expose the "os", "mda", and "users" modules to the Starlark global environment m := &MDA{ parsedListeners: make([]ListenerConfig, 0), } predeclared := starlark.StringDict{ + "kv": kvModule, "mda": m, "os": osModule, "users": usersModule,@@ -233,6 +72,7 @@ Print: func(_ *starlark.Thread, msg string) {
fmt.Printf("[init.star]: %s\n", msg) }, } + thread.SetLocal("context", context.Background()) // 1. Evaluate the script globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, "policy.star", script, predeclared)@@ -289,30 +129,6 @@ }
return relay, nil } -func dictString(dict *starlark.Dict, key string) string { - v, _, err := dict.Get(starlark.String(key)) - if err != nil || v == starlark.None { - return "" - } - if s, ok := v.(starlark.String); ok { - return string(s) - } - return "" -} - -func dictInt(dict *starlark.Dict, key string) int { - v, _, err := dict.Get(starlark.String(key)) - if err != nil || v == starlark.None { - return 0 - } - if i, ok := v.(starlark.Int); ok { - if n, ok := i.Int64(); ok { - return int(n) - } - } - return 0 -} - func (e *Engine) OnMessageDeliver(conn Conn, user User, msg MessageContext) (string, error) { defaultMailbox := "INBOX" hookVal, ok := e.globals["on_message_deliver"]@@ -345,36 +161,6 @@ _, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg, starlark.String(sourceFolder), starlark.String(destFolder)}, nil)
return err } -// MessageContextFromEnvelope builds a policy.MessageContext from the envelope -// fields of a stored message. The header_from address is parsed into an -// Address; if parsing fails the raw string is preserved. -func MessageContextFromEnvelope(from, subject string, size int64) MessageContext { - fromAddr, err := ParseAddress(from) - if err != nil || from == "" { - fromAddr = Address{Raw: from, Address: from} - } - return MessageContext{ - HeaderFrom: fromAddr, - Subject: subject, - Size: size, - } -} - -func ParseAddress(email string) (Address, error) { - a, err := mail.ParseAddress(email) - if err != nil { - return Address{}, err - } - - addr := Address{ - Raw: email, - Address: a.Address, - Name: a.Name, - } - - return addr, nil -} - // starlarkGetenv implements Python's os.getenv("KEY", "default") func starlarkGetenv(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key string@@ -389,3 +175,27 @@ return starlark.String(val), nil
} return def, nil } + +func dictString(dict *starlark.Dict, key string) string { + v, _, err := dict.Get(starlark.String(key)) + if err != nil || v == starlark.None { + return "" + } + if s, ok := v.(starlark.String); ok { + return string(s) + } + return "" +} + +func dictInt(dict *starlark.Dict, key string) int { + v, _, err := dict.Get(starlark.String(key)) + if err != nil || v == starlark.None { + return 0 + } + if i, ok := v.(starlark.Int); ok { + if n, ok := i.Int64(); ok { + return int(n) + } + } + return 0 +}
@@ -27,7 +27,7 @@ func (u *UsersModule) Type() string { return "module" }
func (u *UsersModule) Freeze() {} func (u *UsersModule) Truth() starlark.Bool { return true } func (u *UsersModule) Hash() (uint32, error) { - return 0, nil + return 0, errors.New("unhashable") } func (u *UsersModule) Attr(name string) (starlark.Value, error) {@@ -60,7 +60,7 @@ func (u User) Type() string { return "User" }
func (u User) Freeze() {} func (u User) Truth() starlark.Bool { return true } func (u User) Hash() (uint32, error) { - return 0, nil + return 0, errors.New("unhashable") } func (u User) Attr(name string) (starlark.Value, error) {@@ -145,7 +145,7 @@ func (g *UserGatekeeper) Type() string { return "UserGatekeeper" }
func (g *UserGatekeeper) Freeze() {} func (g *UserGatekeeper) Truth() starlark.Bool { return true } func (g *UserGatekeeper) Hash() (uint32, error) { - return 0, nil + return 0, errors.New("unhashable") } func (g *UserGatekeeper) Attr(name string) (starlark.Value, error) {