From 1c0ad54d2c8a2f89c444ad946caaf5b723389806 Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 16:58:09 +0200 Subject: [PATCH 1/6] README: mark logrus->slog enhancement done, note test-coverage progress The core is now slog-only behind the public team/log package (logrus kept only in one example transport). The sensitive-paths test item is partially progressed: the log package and version/transport paths are unit-tested; certificate/database coverage remains. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e2e11b..6df232c 100644 --- a/README.md +++ b/README.md @@ -388,5 +388,5 @@ library aims to stay small, with a precise role; contributions ideally strengthe code or widen interoperability with other Go programs. - [ ] Add support for encrypted sqlite by default. -- [ ] Finish replacing logrus with the standard-library `slog`, behind a single package shared by client and server. -- [ ] Add tests for the most sensitive paths (certificate management, database, etc.). +- [x] Finish replacing logrus with the standard-library `slog`, behind a single package shared by client and server. _(Core is now `slog`-only, behind the public `team/log` package; logrus remains only in one example transport to demonstrate a self-owned backend.)_ +- [ ] Add tests for the most sensitive paths (certificate management, database, etc.). _(Started: the `log` package and the version/transport paths are now unit-tested; certificate and database coverage still to do.)_ From 2ad08ec08dffd721a8c51e6320b617408f39cf13 Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 17:11:14 +0200 Subject: [PATCH 2/6] Add tests for sensitive paths: certs (PKI), db DSN, user lifecycle Cover the previously-untested security-critical internal packages: - internal/certs: PKI generate/save/get/remove round-trips (ECC & RSA), user client/server certificate lifecycle, ErrCertDoesNotExist and invalid-key-type guards, actual CA chain verification, and RootOnlyVerifyCertificate accept/reject. Coverage ~0 -> ~80%. - internal/db: DSN generation for all dialects with credential URL-escaping, unsupported-dialect error, deterministic param encoding. - server: user-creation validation, token/cert uniqueness, the delete-revokes-auth (+cache) guarantee, and the locked-down mutual-TLS config (RequireAndVerifyClientCert, TLS 1.3). Also fix a latent bug uncovered while testing RootOnlyVerifyCertificate: a malformed CA PEM built an error but never returned it (go vet: "result of fmt.Errorf call not used"). Now returned, with a regression guard test. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- internal/certs/certs_test.go | 340 +++++++++++++++++++++++++++++++++++ internal/certs/tls.go | 2 +- internal/db/config_test.go | 140 +++++++++++++++ server/users_test.go | 189 +++++++++++++++++++ 5 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 internal/certs/certs_test.go create mode 100644 internal/db/config_test.go create mode 100644 server/users_test.go diff --git a/README.md b/README.md index 6df232c..637b349 100644 --- a/README.md +++ b/README.md @@ -389,4 +389,4 @@ code or widen interoperability with other Go programs. - [ ] Add support for encrypted sqlite by default. - [x] Finish replacing logrus with the standard-library `slog`, behind a single package shared by client and server. _(Core is now `slog`-only, behind the public `team/log` package; logrus remains only in one example transport to demonstrate a self-owned backend.)_ -- [ ] Add tests for the most sensitive paths (certificate management, database, etc.). _(Started: the `log` package and the version/transport paths are now unit-tested; certificate and database coverage still to do.)_ +- [x] Add tests for the most sensitive paths (certificate management, database, etc.). _(The `log` package, version/transport flow, the certificate manager (PKI generation, storage round-trips, CA chain verification — now ~80% covered), the database DSN layer, and the teamserver user lifecycle (create/authenticate/delete revocation, mutual-TLS config) are now unit-tested.)_ diff --git a/internal/certs/certs_test.go b/internal/certs/certs_test.go new file mode 100644 index 0000000..8e23219 --- /dev/null +++ b/internal/certs/certs_test.go @@ -0,0 +1,340 @@ +package certs + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "io" + "log/slog" + "testing" + + "gorm.io/gorm" + + "github.com/reeflective/team/internal/assets" + "github.com/reeflective/team/internal/db" +) + +// newTestManager builds a certificate manager backed by an in-memory filesystem +// and an in-memory SQLite database. Constructing the manager also generates the +// user certificate authority, so the returned manager is ready to sign certs. +func newTestManager(t *testing.T) *Manager { + t.Helper() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + dbConfig := &db.Config{ + Dialect: db.Sqlite, + Database: db.SQLiteInMemoryHost, + MaxIdleConns: 1, + MaxOpenConns: 1, + LogLevel: "error", + } + + database, err := db.NewClient(dbConfig, logger) + if err != nil { + t.Fatalf("failed to create in-memory database: %v", err) + } + + fs := assets.NewFileSystem(true) + + return NewManager(fs, database, logger, "testapp", "/app") +} + +// TestNewManagerInitializesCA verifies that constructing a manager creates a +// usable user certificate authority: it is retrievable both parsed and as PEM, +// and the CA key files were written to the (in-memory) filesystem. +func TestNewManagerInitializesCA(t *testing.T) { + certs := newTestManager(t) + + caCert, caKey, err := certs.GetUsersCA() + if err != nil { + t.Fatalf("GetUsersCA: %v", err) + } + if caCert == nil || caKey == nil { + t.Fatal("GetUsersCA returned nil certificate or key") + } + if !caCert.IsCA { + t.Fatal("user CA certificate is not marked as a CA") + } + + certPEM, keyPEM, err := certs.GetUsersCAPEM() + if err != nil { + t.Fatalf("GetUsersCAPEM: %v", err) + } + if len(certPEM) == 0 || len(keyPEM) == 0 { + t.Fatal("GetUsersCAPEM returned empty certificate or key") + } + if block, _ := pem.Decode(certPEM); block == nil || block.Type != "CERTIFICATE" { + t.Fatal("CA certificate PEM is not a valid CERTIFICATE block") + } +} + +// TestECCCertificateRoundTrip exercises the full lifecycle of an ECC leaf +// certificate: generate + save, fetch it back byte-for-byte, then remove it and +// confirm it is gone. +func TestECCCertificateRoundTrip(t *testing.T) { + certs := newTestManager(t) + + cn := "roundtrip.example.com" + cert, key := certs.GenerateECCCertificate(userCA, cn, false, false) + if len(cert) == 0 || len(key) == 0 { + t.Fatal("GenerateECCCertificate returned empty material") + } + + if err := certs.saveCertificate(userCA, ECCKey, cn, cert, key); err != nil { + t.Fatalf("saveCertificate: %v", err) + } + + gotCert, gotKey, err := certs.GetECCCertificate(userCA, cn) + if err != nil { + t.Fatalf("GetECCCertificate: %v", err) + } + if !bytes.Equal(cert, gotCert) || !bytes.Equal(key, gotKey) { + t.Fatal("fetched certificate/key does not match the stored material") + } + + if err := certs.RemoveCertificate(userCA, ECCKey, cn); err != nil { + t.Fatalf("RemoveCertificate: %v", err) + } + + if _, _, err := certs.GetECCCertificate(userCA, cn); err != ErrCertDoesNotExist { + t.Fatalf("expected ErrCertDoesNotExist after removal, got %v", err) + } +} + +// TestRSACertificateRoundTrip does the same lifecycle check for RSA material, +// covering the RSA key generation and PEM-encoding branches. +func TestRSACertificateRoundTrip(t *testing.T) { + certs := newTestManager(t) + + cn := "rsa.example.com" + cert, key := certs.GenerateRSACertificate(userCA, cn, false, false) + if len(cert) == 0 || len(key) == 0 { + t.Fatal("GenerateRSACertificate returned empty material") + } + + if block, _ := pem.Decode(key); block == nil || block.Type != "RSA PRIVATE KEY" { + t.Fatal("RSA private key PEM block is malformed") + } + + if err := certs.saveCertificate(userCA, RSAKey, cn, cert, key); err != nil { + t.Fatalf("saveCertificate: %v", err) + } + + gotCert, gotKey, err := certs.GetRSACertificate(userCA, cn) + if err != nil { + t.Fatalf("GetRSACertificate: %v", err) + } + if !bytes.Equal(cert, gotCert) || !bytes.Equal(key, gotKey) { + t.Fatal("fetched RSA certificate/key does not match stored material") + } +} + +// TestGetCertificateNotFound confirms that fetching an unknown certificate +// returns the sentinel error and no material. +func TestGetCertificateNotFound(t *testing.T) { + certs := newTestManager(t) + + cert, key, err := certs.GetECCCertificate(userCA, "nobody.example.com") + if err != ErrCertDoesNotExist { + t.Fatalf("expected ErrCertDoesNotExist, got %v", err) + } + if cert != nil || key != nil { + t.Fatal("expected nil material for a missing certificate") + } +} + +// TestInvalidKeyTypeRejected ensures the key-type guard rejects unknown key +// namespaces on every entry point that takes one. +func TestInvalidKeyTypeRejected(t *testing.T) { + certs := newTestManager(t) + + if _, _, err := certs.GetCertificate(userCA, "dsa", "x"); err == nil { + t.Fatal("GetCertificate accepted an invalid key type") + } + if err := certs.RemoveCertificate(userCA, "dsa", "x"); err == nil { + t.Fatal("RemoveCertificate accepted an invalid key type") + } + if err := certs.saveCertificate(userCA, "dsa", "x", nil, nil); err == nil { + t.Fatal("saveCertificate accepted an invalid key type") + } +} + +// TestUserClientCertificateLifecycle covers the user-facing helpers used when a +// teamserver mints, lists and revokes a client's credentials. +func TestUserClientCertificateLifecycle(t *testing.T) { + certs := newTestManager(t) + + if _, _, err := certs.UserClientGenerateCertificate("alice"); err != nil { + t.Fatalf("UserClientGenerateCertificate: %v", err) + } + + cert, key, err := certs.UserClientGetCertificate("alice") + if err != nil { + t.Fatalf("UserClientGetCertificate: %v", err) + } + if len(cert) == 0 || len(key) == 0 { + t.Fatal("client certificate material is empty") + } + + listed := certs.UserClientListCertificates() + if len(listed) != 1 { + t.Fatalf("expected exactly 1 listed client certificate, got %d", len(listed)) + } + if listed[0].Subject.CommonName != "alice" { + t.Fatalf("listed certificate CN = %q, want alice", listed[0].Subject.CommonName) + } + + if err := certs.UserClientRemoveCertificate("alice"); err != nil { + t.Fatalf("UserClientRemoveCertificate: %v", err) + } + if _, _, err := certs.UserClientGetCertificate("alice"); err != ErrCertDoesNotExist { + t.Fatalf("expected ErrCertDoesNotExist after revocation, got %v", err) + } + if got := certs.UserClientListCertificates(); len(got) != 0 { + t.Fatalf("expected no client certificates after revocation, got %d", len(got)) + } +} + +// TestUserServerCertificate covers the lazy generate-then-fetch pattern used by +// UsersTLSConfig for the server-side leaf certificate. +func TestUserServerCertificate(t *testing.T) { + certs := newTestManager(t) + + // Not generated yet. + if _, _, err := certs.UserServerGetCertificate(); err != ErrCertDoesNotExist { + t.Fatalf("expected ErrCertDoesNotExist before generation, got %v", err) + } + + if _, _, err := certs.UserServerGenerateCertificate(); err != nil { + t.Fatalf("UserServerGenerateCertificate: %v", err) + } + + cert, key, err := certs.UserServerGetCertificate() + if err != nil { + t.Fatalf("UserServerGetCertificate: %v", err) + } + if len(cert) == 0 || len(key) == 0 { + t.Fatal("server certificate material is empty") + } +} + +// TestClientCertificateChainsToCA is the core PKI assertion: a generated client +// certificate must actually verify against the user CA. This proves the signing +// chain (not just that bytes round-trip through the database). +func TestClientCertificateChainsToCA(t *testing.T) { + certs := newTestManager(t) + + caCert, _, err := certs.GetUsersCA() + if err != nil { + t.Fatalf("GetUsersCA: %v", err) + } + + leafPEM, _, err := certs.UserClientGenerateCertificate("bob") + if err != nil { + t.Fatalf("UserClientGenerateCertificate: %v", err) + } + + block, _ := pem.Decode(leafPEM) + if block == nil { + t.Fatal("failed to decode leaf certificate PEM") + } + leaf, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("ParseCertificate(leaf): %v", err) + } + + roots := x509.NewCertPool() + roots.AddCert(caCert) + + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }); err != nil { + t.Fatalf("client certificate does not chain to the user CA: %v", err) + } +} + +// TestRootOnlyVerifyCertificate revives the (previously commented-out) contract +// for the hostname-skipping verifier: a certificate signed by the CA passes, +// one signed by a different CA is rejected. +func TestRootOnlyVerifyCertificate(t *testing.T) { + certs := newTestManager(t) + + caPEM, _, err := certs.GetUsersCAPEM() + if err != nil { + t.Fatalf("GetUsersCAPEM: %v", err) + } + + // RootOnlyVerifyCertificate skips only the hostname check; it still enforces + // Go's default EKU (server-auth), so we verify with server certificates. + // + // A leaf signed by our CA must verify. + leafPEM, _ := certs.GenerateECCCertificate(userCA, "localhost", false, false) + leafBlock, _ := pem.Decode(leafPEM) + if leafBlock == nil { + t.Fatal("failed to decode leaf certificate PEM") + } + if err := RootOnlyVerifyCertificate(string(caPEM), [][]byte{leafBlock.Bytes}); err != nil { + t.Fatalf("RootOnlyVerifyCertificate rejected a validly-signed cert: %v", err) + } + + // A leaf signed by a DIFFERENT CA must be rejected. + other := newTestManagerWithApp(t, "otherapp", "/other") + foreignPEM, _ := other.GenerateECCCertificate(userCA, "localhost", false, false) + foreignBlock, _ := pem.Decode(foreignPEM) + if foreignBlock == nil { + t.Fatal("failed to decode foreign certificate PEM") + } + if err := RootOnlyVerifyCertificate(string(caPEM), [][]byte{foreignBlock.Bytes}); err == nil { + t.Fatal("RootOnlyVerifyCertificate accepted a certificate signed by a foreign CA") + } + + // A malformed CA PEM must be rejected outright (regression guard: the error + // used to be constructed but never returned). + if err := RootOnlyVerifyCertificate("not a pem", [][]byte{leafBlock.Bytes}); err == nil { + t.Fatal("RootOnlyVerifyCertificate accepted a malformed root certificate") + } +} + +// newTestManagerWithApp is like newTestManager but lets a test create a second, +// independent CA (distinct app name + filesystem root + database). +func newTestManagerWithApp(t *testing.T, appName, appDir string) *Manager { + t.Helper() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + dbConfig := &db.Config{ + Dialect: db.Sqlite, + Database: db.SQLiteInMemoryHost, + MaxIdleConns: 1, + MaxOpenConns: 1, + LogLevel: "error", + } + + var database *gorm.DB + database, err := db.NewClient(dbConfig, logger) + if err != nil { + t.Fatalf("failed to create in-memory database: %v", err) + } + + return NewManager(assets.NewFileSystem(true), database, logger, appName, appDir) +} diff --git a/internal/certs/tls.go b/internal/certs/tls.go index 8bcbcd5..b88c2bd 100644 --- a/internal/certs/tls.go +++ b/internal/certs/tls.go @@ -60,7 +60,7 @@ func RootOnlyVerifyCertificate(caCertificate string, rawCerts [][]byte) error { ok := roots.AppendCertsFromPEM([]byte(caCertificate)) if !ok { - fmt.Errorf("Failed to parse root certificate") + return fmt.Errorf("Failed to parse root certificate") } cert, err := x509.ParseCertificate(rawCerts[0]) // We should only get one cert diff --git a/internal/db/config_test.go b/internal/db/config_test.go new file mode 100644 index 0000000..114072a --- /dev/null +++ b/internal/db/config_test.go @@ -0,0 +1,140 @@ +package db + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "errors" + "net/url" + "strings" + "testing" +) + +// TestDSNSqlite pins the SQLite DSN format: a file: URI with the database path +// and encoded params. This is the default/in-memory backend and the one path +// the integration tests already exercise, kept here as an explicit contract. +func TestDSNSqlite(t *testing.T) { + cfg := &Config{ + Dialect: Sqlite, + Database: SQLiteInMemoryHost, + Params: map[string]string{"cache": "shared"}, + } + + dsn, err := cfg.DSN() + if err != nil { + t.Fatalf("DSN(sqlite): unexpected error %v", err) + } + + if !strings.HasPrefix(dsn, "file::memory:?") { + t.Fatalf("sqlite DSN must be a file: URI for the in-memory host, got %q", dsn) + } + if !strings.Contains(dsn, "cache=shared") { + t.Fatalf("sqlite DSN must carry encoded params, got %q", dsn) + } +} + +// TestDSNMySQL checks the go-sql-driver/mysql DSN layout and, importantly, that +// credentials and database names are URL-query-escaped so that special +// characters in a password cannot corrupt the DSN. +func TestDSNMySQL(t *testing.T) { + cfg := &Config{ + Dialect: MySQL, + Username: "team user", + Password: "p@ss:w/rd", + Database: "team db", + Host: "db.example.com", + Port: 3306, + } + + dsn, err := cfg.DSN() + if err != nil { + t.Fatalf("DSN(mysql): unexpected error %v", err) + } + + // user:password@tcp(host:port)/db?params + if !strings.Contains(dsn, "@tcp(db.example.com:3306)/") { + t.Fatalf("mysql DSN missing tcp host section, got %q", dsn) + } + if !strings.Contains(dsn, url.QueryEscape("p@ss:w/rd")) { + t.Fatalf("mysql DSN must URL-escape the password, got %q", dsn) + } + // The raw, unescaped password must not leak into the DSN. + if strings.Contains(dsn, "p@ss:w/rd") { + t.Fatalf("mysql DSN leaked the raw unescaped password, got %q", dsn) + } +} + +// TestDSNPostgres checks the key=value Postgres DSN layout and that all +// user-controlled fields are URL-escaped. +func TestDSNPostgres(t *testing.T) { + cfg := &Config{ + Dialect: Postgres, + Username: "team user", + Password: "p@ss word", + Database: "team db", + Host: "db.example.com", + Port: 5432, + } + + dsn, err := cfg.DSN() + if err != nil { + t.Fatalf("DSN(postgres): unexpected error %v", err) + } + + for _, want := range []string{ + "host=db.example.com", + "port=5432", + "user=" + url.QueryEscape("team user"), + "password=" + url.QueryEscape("p@ss word"), + "dbname=" + url.QueryEscape("team db"), + } { + if !strings.Contains(dsn, want) { + t.Fatalf("postgres DSN missing %q, got %q", want, dsn) + } + } +} + +// TestDSNUnsupportedDialect ensures an unknown dialect is rejected with the +// sentinel error rather than producing a bogus connection string. +func TestDSNUnsupportedDialect(t *testing.T) { + cfg := &Config{Dialect: "oracle"} + + dsn, err := cfg.DSN() + if err == nil { + t.Fatalf("DSN(unsupported): expected an error, got dsn %q", dsn) + } + if !errors.Is(err, ErrUnsupportedDialect) { + t.Fatalf("DSN(unsupported): expected ErrUnsupportedDialect, got %v", err) + } + if dsn != "" { + t.Fatalf("DSN(unsupported): expected empty dsn on error, got %q", dsn) + } +} + +// TestEncodeParams verifies params are deterministically URL-encoded (sorted by +// key) so DSNs are stable, and that an empty map yields an empty string. +func TestEncodeParams(t *testing.T) { + if got := encodeParams(nil); got != "" { + t.Fatalf("encodeParams(nil): expected empty, got %q", got) + } + + got := encodeParams(map[string]string{"b": "2", "a": "1"}) + if got != "a=1&b=2" { + t.Fatalf("encodeParams: expected deterministic sorted output a=1&b=2, got %q", got) + } +} diff --git a/server/users_test.go b/server/users_test.go new file mode 100644 index 0000000..fc9e520 --- /dev/null +++ b/server/users_test.go @@ -0,0 +1,189 @@ +package server + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "crypto/tls" + "errors" + "testing" +) + +// newTestServer returns a fully-initialized in-memory teamserver. Calling init() +// bootstraps the database and certificate infrastructure without needing a +// transport handler, which is all the user-management primitives require. +func newTestServer(t *testing.T) *Server { + t.Helper() + + ts, err := New("usertest", WithInMemory()) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + if err := ts.init(); err != nil { + t.Fatalf("server.init: %v", err) + } + + return ts +} + +// TestUserCreateValidation pins the input validation on UserCreate: user names +// are restricted to alphanumerics (plus - and _), and neither the name nor the +// host may be empty. All rejections surface as ErrUserConfig. +func TestUserCreateValidation(t *testing.T) { + ts := newTestServer(t) + + cases := []struct { + name string + user string + lhost string + }{ + {"empty name", "", "localhost"}, + {"empty host", "alice", ""}, + {"space in name", "alice bob", "localhost"}, + {"slash in name", "alice/bob", "localhost"}, + {"dot in name", "alice.bob", "localhost"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := ts.UserCreate(tc.user, tc.lhost, 31337) + if err == nil { + t.Fatalf("UserCreate(%q, %q) should have failed", tc.user, tc.lhost) + } + if !errors.Is(err, ErrUserConfig) { + t.Fatalf("expected ErrUserConfig, got %v", err) + } + if cfg != nil { + t.Fatal("expected nil config on validation failure") + } + }) + } +} + +// TestUserCreateTokenUniqueness ensures two users minted back-to-back receive +// distinct API tokens and distinct client certificates, and that each token +// authenticates back to the right identity. +func TestUserCreateTokenUniqueness(t *testing.T) { + ts := newTestServer(t) + + alice, err := ts.UserCreate("alice", "localhost", 31337) + if err != nil { + t.Fatalf("UserCreate(alice): %v", err) + } + bob, err := ts.UserCreate("bob", "localhost", 31337) + if err != nil { + t.Fatalf("UserCreate(bob): %v", err) + } + + if alice.Token == "" || bob.Token == "" { + t.Fatal("tokens must be non-empty") + } + if alice.Token == bob.Token { + t.Fatal("two users must not share the same API token") + } + if alice.Certificate == bob.Certificate { + t.Fatal("two users must not share the same client certificate") + } + + if u, err := ts.Authenticate(alice.Token); err != nil || u == nil || u.Name != "alice" { + t.Fatalf("alice token must authenticate as alice, got user=%v err=%v", u, err) + } + if u, err := ts.Authenticate(bob.Token); err != nil || u == nil || u.Name != "bob" { + t.Fatalf("bob token must authenticate as bob, got user=%v err=%v", u, err) + } +} + +// TestUserDeleteRevokesAuth is the security-critical guarantee documented on +// UserDelete: once a user is deleted, its token no longer authenticates (even +// though it was previously cached) and its client certificate is gone. +func TestUserDeleteRevokesAuth(t *testing.T) { + ts := newTestServer(t) + + cfg, err := ts.UserCreate("mallory", "localhost", 31337) + if err != nil { + t.Fatalf("UserCreate: %v", err) + } + + // Authenticate once so the token is now in the in-memory cache; deletion + // must invalidate the cache too, not just the database row. + if _, err := ts.Authenticate(cfg.Token); err != nil { + t.Fatalf("Authenticate before delete: %v", err) + } + + // The client certificate must exist before deletion. + if _, _, err := ts.certs.UserClientGetCertificate("mallory"); err != nil { + t.Fatalf("client certificate should exist before delete: %v", err) + } + + if err := ts.UserDelete("mallory"); err != nil { + t.Fatalf("UserDelete: %v", err) + } + + // Token must no longer authenticate. + user, err := ts.Authenticate(cfg.Token) + if err == nil || user != nil { + t.Fatalf("deleted user's token must be rejected, got user=%v err=%v", user, err) + } + if !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("expected ErrUnauthenticated after delete, got %v", err) + } + + // The client certificate must be gone. + if _, _, err := ts.certs.UserClientGetCertificate("mallory"); err == nil { + t.Fatal("client certificate should have been removed on delete") + } +} + +// TestUsersTLSConfig verifies the server-side mutual-TLS configuration is locked +// down: it requires and verifies client certificates, pins TLS 1.3, and carries +// exactly one server certificate plus a client CA pool. +func TestUsersTLSConfig(t *testing.T) { + ts := newTestServer(t) + + tlsConfig, err := ts.UsersTLSConfig() + if err != nil { + t.Fatalf("UsersTLSConfig: %v", err) + } + + if tlsConfig.ClientAuth != tls.RequireAndVerifyClientCert { + t.Fatalf("expected RequireAndVerifyClientCert, got %v", tlsConfig.ClientAuth) + } + if tlsConfig.MinVersion != tls.VersionTLS13 { + t.Fatalf("expected MinVersion TLS 1.3, got %x", tlsConfig.MinVersion) + } + if len(tlsConfig.Certificates) != 1 { + t.Fatalf("expected exactly 1 server certificate, got %d", len(tlsConfig.Certificates)) + } + if tlsConfig.ClientCAs == nil || tlsConfig.RootCAs == nil { + t.Fatal("expected both a client CA pool and a root CA pool to be set") + } +} + +// TestAuthenticateRejectsGarbage ensures a well-formed-but-unknown token and an +// empty token are both rejected without leaking an identity. +func TestAuthenticateRejectsGarbage(t *testing.T) { + ts := newTestServer(t) + + for _, tok := range []string{"", "deadbeef", "not-a-real-token"} { + user, err := ts.Authenticate(tok) + if err == nil || user != nil { + t.Fatalf("token %q must be rejected, got user=%v err=%v", tok, user, err) + } + } +} From 401643db603e37b9406a7387f657dafff2011449 Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 17:36:58 +0200 Subject: [PATCH 3/6] db: add opt-in encrypted SQLite at rest; drop vendored gormlite fork Add transparent encryption-at-rest for the default file-based SQLite database via server.WithDatabaseKey(key). The key is fed to the pure-Go adiantum VFS (ncruces/go-sqlite3) as a textkey, so it works on the default and wasm_sqlite builds with no CGO and no external tooling. Design (opt-in): leaving the key unset keeps today's plaintext behavior; in-memory databases are never encrypted (nothing on disk); the key is marked json:"-" and applied out-of-band in initDatabase, so it is never persisted next to the database it protects. Also: - Harden db.NewClient: force a page read for SQLite before AutoMigrate, so a wrong key (or corrupt file) returns a clean error instead of panicking inside gormlite's schema introspection. - Remove the vendored internal/db/wasmsqlite fork: it was a stale copy of ncruces' official gormlite. The wasm_sqlite build now uses the upstream module, identical to the default build. - Bump ncruces/go-sqlite3 and gormlite v0.8.4 -> v0.22.0 (adiantum lives there); gorm moves v1.25.10 -> v1.25.12 (patch). Tests (excluded on cgo_sqlite, which lacks the adiantum VFS): - DSN encryption/plaintext/in-memory selection. - End-to-end at-rest proof at the db layer and through the server's WithDatabaseKey option: on-disk file has no SQLite header and no cleartext data; wrong key is rejected; correct key round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- go.mod | 28 +- go.sum | 65 +-- internal/db/config.go | 24 +- internal/db/config_test.go | 60 +++ internal/db/encryption_test.go | 144 ++++++ internal/db/sql-go.go | 3 + internal/db/sql-wasm.go | 12 +- internal/db/sql.go | 10 + internal/db/wasmsqlite/License | 21 - internal/db/wasmsqlite/README.md | 56 --- internal/db/wasmsqlite/ddlmod.go | 234 ---------- internal/db/wasmsqlite/ddlmod_test.go | 247 ---------- internal/db/wasmsqlite/errors.go | 7 - internal/db/wasmsqlite/migrator.go | 423 ------------------ internal/db/wasmsqlite/sqlite.go | 224 ---------- internal/db/wasmsqlite/sqlite_test.go | 122 ----- internal/db/wasmsqlite/sqlite_version_test.go | 23 - server/db.go | 6 + server/encryption_test.go | 76 ++++ server/options.go | 21 + 21 files changed, 367 insertions(+), 1441 deletions(-) create mode 100644 internal/db/encryption_test.go delete mode 100644 internal/db/wasmsqlite/License delete mode 100644 internal/db/wasmsqlite/README.md delete mode 100644 internal/db/wasmsqlite/ddlmod.go delete mode 100644 internal/db/wasmsqlite/ddlmod_test.go delete mode 100644 internal/db/wasmsqlite/errors.go delete mode 100644 internal/db/wasmsqlite/migrator.go delete mode 100644 internal/db/wasmsqlite/sqlite.go delete mode 100644 internal/db/wasmsqlite/sqlite_test.go delete mode 100644 internal/db/wasmsqlite/sqlite_version_test.go create mode 100644 server/encryption_test.go diff --git a/README.md b/README.md index 637b349..7c009d4 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,6 @@ Not a roadmap — these are changes the author would gladly review contributions library aims to stay small, with a precise role; contributions ideally strengthen the core/transport code or widen interoperability with other Go programs. -- [ ] Add support for encrypted sqlite by default. +- [x] Add support for encrypted sqlite. _(Opt-in via `server.WithDatabaseKey(key)`: the default, file-based SQLite database is then transparently encrypted at rest through the pure-Go [adiantum](https://github.com/ncruces/go-sqlite3/tree/main/vfs/adiantum) VFS — no CGO, works on the default and `wasm_sqlite` builds. The key is never persisted next to the database. Leaving it unset keeps the current plaintext behavior.)_ - [x] Finish replacing logrus with the standard-library `slog`, behind a single package shared by client and server. _(Core is now `slog`-only, behind the public `team/log` package; logrus remains only in one example transport to demonstrate a self-owned backend.)_ - [x] Add tests for the most sensitive paths (certificate management, database, etc.). _(The `log` package, version/transport flow, the certificate manager (PKI generation, storage round-trips, CA chain verification — now ~80% covered), the database DSN layer, and the teamserver user lifecycle (create/authenticate/delete revocation, mutual-TLS config) are now unit-tested.)_ diff --git a/go.mod b/go.mod index 0950fdf..7018a13 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,8 @@ require ( github.com/gofrs/uuid v4.4.0+incompatible github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/jedib0t/go-pretty/v6 v6.4.6 - github.com/lib/pq v1.10.9 - github.com/ncruces/go-sqlite3 v0.8.4 - github.com/ncruces/go-sqlite3/gormlite v0.8.4 + github.com/ncruces/go-sqlite3 v0.22.0 + github.com/ncruces/go-sqlite3/gormlite v0.22.0 github.com/rsteube/carapace v0.47.4 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.14.0 @@ -23,16 +22,14 @@ require ( gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.9 gorm.io/driver/sqlite v1.5.5 - gorm.io/gorm v1.25.10 - modernc.org/sqlite v1.23.1 + gorm.io/gorm v1.25.12 ) require ( github.com/carapace-sh/carapace-shlex v1.0.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -46,30 +43,19 @@ require ( github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect - github.com/ncruces/julianday v0.1.5 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/ncruces/julianday v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rsteube/carapace-shlex v0.1.1 // indirect github.com/stretchr/testify v1.8.2 // indirect - github.com/tetratelabs/wazero v1.4.0 // indirect + github.com/tetratelabs/wazero v1.8.2 // indirect golang.org/x/crypto v0.37.0 // indirect - golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/uint128 v1.2.0 // indirect - modernc.org/cc/v3 v3.40.0 // indirect - modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/opt v0.1.3 // indirect - modernc.org/strutil v1.1.3 // indirect - modernc.org/token v1.0.1 // indirect + lukechampine.com/adiantum v1.1.1 // indirect ) diff --git a/go.sum b/go.sum index d7abf50..619f95e 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -51,10 +49,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -86,8 +80,6 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -101,21 +93,18 @@ github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6 github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/ncruces/go-sqlite3 v0.8.4 h1:nizhgJMMJJBrthESCwF30+oOvQkdtizgJ/v35Y0v+vg= -github.com/ncruces/go-sqlite3 v0.8.4/go.mod h1:XvDtjKk5MgwHX7L4I7BPzzKl36bTZ7+Hr6Kr2QeVkVw= -github.com/ncruces/go-sqlite3/gormlite v0.8.4 h1:omeGR0XofGGwlbWB5QSEdPQC0j58fDEULrVMLXTIt+M= -github.com/ncruces/go-sqlite3/gormlite v0.8.4/go.mod h1:52uZNxrd8iQVjmxE6l3Dt71zoHpwnoDDFIqB1wW1+Cg= -github.com/ncruces/julianday v0.1.5 h1:hDJ9ejiMp3DHsoZ5KW4c1lwfMjbARS7u/gbYcd0FBZk= -github.com/ncruces/julianday v0.1.5/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/ncruces/go-sqlite3 v0.22.0 h1:FkGSBhd0TY6e66k1LVhyEpA+RnG/8QkQNed5pjIk4cs= +github.com/ncruces/go-sqlite3 v0.22.0/go.mod h1:ueXOZXYZS2OFQirCU3mHneDwJm5fGKHrtccYBeGEV7M= +github.com/ncruces/go-sqlite3/gormlite v0.22.0 h1:1kn6lXjiBe+5up9KJ24dqGixbr+L6sQFrXc4ay9IMqg= +github.com/ncruces/go-sqlite3/gormlite v0.22.0/go.mod h1:R0/mKQ7ahIY32z4ZlCVuwvSjlPbN9PN3b9NxyE3AzFQ= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= @@ -150,8 +139,8 @@ github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/tetratelabs/wazero v1.4.0 h1:9/MirYvmkJ/zSUOygKY/ia3t+e+RqIZXKbylIby1WYk= -github.com/tetratelabs/wazero v1.4.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A= +github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= +github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -173,8 +162,6 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -236,8 +223,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -279,35 +264,9 @@ gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkw gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= -gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= -lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= -modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= -modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= -modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= -modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= -modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= -modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= -modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= -modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= -modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= -modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= +lukechampine.com/adiantum v1.1.1 h1:4fp6gTxWCqpEbLy40ExiYDDED3oUNWx5cTqBCtPdZqA= +lukechampine.com/adiantum v1.1.1/go.mod h1:LrAYVnTYLnUtE/yMp5bQr0HstAf060YUF8nM0B6+rUw= diff --git a/internal/db/config.go b/internal/db/config.go index 6b95e61..598a88c 100644 --- a/internal/db/config.go +++ b/internal/db/config.go @@ -47,6 +47,13 @@ type Config struct { MaxOpenConns int `json:"max_open_conns"` LogLevel string `json:"log_level"` + + // EncryptionKey, when set, enables transparent encryption-at-rest for + // on-disk SQLite databases through the pure-Go adiantum VFS (available on + // the default and wasm_sqlite builds). It is deliberately NOT serialized: + // persisting the key next to the database it protects would defeat the + // purpose. Applications supply it out-of-band (option, env, KMS, prompt). + EncryptionKey string `json:"-"` } // DSN - Get the db connections string @@ -55,9 +62,22 @@ func (c *Config) DSN() (string, error) { switch c.Dialect { case Sqlite: filePath := c.Database - params := encodeParams(c.Params) - return fmt.Sprintf("file:%s?%s", filePath, params), nil + params := url.Values{} + for key, value := range c.Params { + params.Add(key, value) + } + + // Enable transparent encryption-at-rest when a key is provided and the + // database actually lives on disk. The adiantum VFS is pure-Go, so this + // works on the default and wasm_sqlite builds; in-memory databases have + // nothing on disk to encrypt, and the cgo_sqlite build has no such VFS. + if c.EncryptionKey != "" && filePath != SQLiteInMemoryHost { + params.Set("vfs", "adiantum") + params.Set("textkey", c.EncryptionKey) + } + + return fmt.Sprintf("file:%s?%s", filePath, params.Encode()), nil case MySQL: user := url.QueryEscape(c.Username) diff --git a/internal/db/config_test.go b/internal/db/config_test.go index 114072a..d04336b 100644 --- a/internal/db/config_test.go +++ b/internal/db/config_test.go @@ -48,6 +48,66 @@ func TestDSNSqlite(t *testing.T) { } } +// TestDSNSqliteEncrypted verifies that supplying an encryption key routes an +// on-disk SQLite database through the adiantum VFS, with the key carried as a +// (URL-encoded) textkey parameter. +func TestDSNSqliteEncrypted(t *testing.T) { + cfg := &Config{ + Dialect: Sqlite, + Database: "/var/lib/team/app.db", + EncryptionKey: "s3cr3t key/with=chars", + } + + dsn, err := cfg.DSN() + if err != nil { + t.Fatalf("DSN(encrypted sqlite): %v", err) + } + + if !strings.Contains(dsn, "vfs=adiantum") { + t.Fatalf("encrypted DSN must select the adiantum VFS, got %q", dsn) + } + if !strings.Contains(dsn, "textkey="+url.QueryEscape("s3cr3t key/with=chars")) { + t.Fatalf("encrypted DSN must carry the URL-encoded textkey, got %q", dsn) + } + // The raw key with its unescaped special characters must not appear. + if strings.Contains(dsn, "s3cr3t key/with=chars") { + t.Fatalf("encrypted DSN leaked an unescaped key, got %q", dsn) + } +} + +// TestDSNSqliteInMemoryNotEncrypted ensures the encryption key is ignored for +// in-memory databases: there is nothing on disk to protect, and selecting the +// adiantum VFS there would only add overhead. +func TestDSNSqliteInMemoryNotEncrypted(t *testing.T) { + cfg := &Config{ + Dialect: Sqlite, + Database: SQLiteInMemoryHost, + EncryptionKey: "ignored-for-memory", + } + + dsn, err := cfg.DSN() + if err != nil { + t.Fatalf("DSN(in-memory): %v", err) + } + if strings.Contains(dsn, "adiantum") || strings.Contains(dsn, "textkey") { + t.Fatalf("in-memory DSN must not be encrypted, got %q", dsn) + } +} + +// TestDSNSqlitePlaintextByDefault pins the opt-in contract: with no key, the +// DSN is the plain file: URI with no VFS selected. +func TestDSNSqlitePlaintextByDefault(t *testing.T) { + cfg := &Config{Dialect: Sqlite, Database: "/var/lib/team/app.db"} + + dsn, err := cfg.DSN() + if err != nil { + t.Fatalf("DSN(plaintext): %v", err) + } + if strings.Contains(dsn, "adiantum") || strings.Contains(dsn, "textkey") { + t.Fatalf("default DSN must be plaintext (opt-in encryption), got %q", dsn) + } +} + // TestDSNMySQL checks the go-sql-driver/mysql DSN layout and, importantly, that // credentials and database names are URL-query-escaped so that special // characters in a password cannot corrupt the DSN. diff --git a/internal/db/encryption_test.go b/internal/db/encryption_test.go new file mode 100644 index 0000000..f5742e5 --- /dev/null +++ b/internal/db/encryption_test.go @@ -0,0 +1,144 @@ +//go:build !cgo_sqlite + +package db + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "bytes" + "io" + "log/slog" + "os" + "path/filepath" + "testing" +) + +// The adiantum encryption VFS is only available on the pure-Go builds (default +// and wasm_sqlite); the cgo_sqlite build uses a different SQLite engine, so this +// file is excluded there via the build constraint above. + +const plaintextMarker = "ENCRYPTION_AT_REST_PLAINTEXT_MARKER" + +func newTestDBConfig(path, key string) *Config { + return &Config{ + Dialect: Sqlite, + Database: path, + MaxIdleConns: 1, + MaxOpenConns: 1, + LogLevel: "error", + EncryptionKey: key, + } +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// closeDB releases the underlying connection so the file is flushed and can be +// reopened / inspected. +func closeDB(t *testing.T, cfg *Config) { + t.Helper() + + client, err := NewClient(cfg, discardLogger()) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + if err := client.Create(&Certificate{CommonName: plaintextMarker}).Error; err != nil { + t.Fatalf("insert marker: %v", err) + } + + sqlDB, err := client.DB() + if err != nil { + t.Fatalf("client.DB: %v", err) + } + if err := sqlDB.Close(); err != nil { + t.Fatalf("close: %v", err) + } +} + +// TestEncryptedDatabaseAtRest is the end-to-end proof that WithDatabaseKey +// actually encrypts the database on disk: the file contains neither the +// plaintext marker nor the recognizable SQLite header, a wrong key cannot read +// it, and the correct key round-trips the data. +func TestEncryptedDatabaseAtRest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "encrypted.db") + const key = "correct horse battery staple" + + // 1. Write with a key, then close. + closeDB(t, newTestDBConfig(path, key)) + + // 2. The on-disk file must be encrypted. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read db file: %v", err) + } + if len(raw) == 0 { + t.Fatal("database file is empty") + } + if bytes.Contains(raw, []byte(plaintextMarker)) { + t.Fatal("plaintext marker found in the on-disk database: not encrypted") + } + if bytes.HasPrefix(raw, []byte("SQLite format 3")) { + t.Fatal("unencrypted SQLite header present in the on-disk database") + } + + // 3. A wrong key must not be able to read the data. + wrong, err := NewClient(newTestDBConfig(path, "wrong key"), discardLogger()) + if err == nil { + var got Certificate + if err := wrong.Where(&Certificate{CommonName: plaintextMarker}).First(&got).Error; err == nil { + t.Fatal("a wrong key was able to decrypt and read the database") + } + if sqlDB, derr := wrong.DB(); derr == nil { + sqlDB.Close() + } + } + + // 4. The correct key round-trips the data. + right, err := NewClient(newTestDBConfig(path, key), discardLogger()) + if err != nil { + t.Fatalf("NewClient(correct key): %v", err) + } + var got Certificate + if err := right.Where(&Certificate{CommonName: plaintextMarker}).First(&got).Error; err != nil { + t.Fatalf("correct key failed to read the marker back: %v", err) + } + if got.CommonName != plaintextMarker { + t.Fatalf("round-trip mismatch: got %q", got.CommonName) + } +} + +// TestUnencryptedDatabaseIsPlaintext is the control: with no key, the same file +// is a normal SQLite database (opt-in encryption leaves the default untouched). +func TestUnencryptedDatabaseIsPlaintext(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plain.db") + + closeDB(t, newTestDBConfig(path, "")) + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read db file: %v", err) + } + if !bytes.HasPrefix(raw, []byte("SQLite format 3")) { + t.Fatal("expected a standard SQLite header for an unencrypted database") + } +} diff --git a/internal/db/sql-go.go b/internal/db/sql-go.go index 302aa0f..20553b5 100644 --- a/internal/db/sql-go.go +++ b/internal/db/sql-go.go @@ -24,6 +24,9 @@ import ( // Embed the sqlite code into our teamserver. _ "github.com/ncruces/go-sqlite3/embed" "github.com/ncruces/go-sqlite3/gormlite" + // Register the pure-Go adiantum VFS so an encryption key in the DSN + // (vfs=adiantum&textkey=...) transparently encrypts the database at rest. + _ "github.com/ncruces/go-sqlite3/vfs/adiantum" "gorm.io/gorm" "gorm.io/gorm/logger" ) diff --git a/internal/db/sql-wasm.go b/internal/db/sql-wasm.go index 213b48d..3997eba 100644 --- a/internal/db/sql-wasm.go +++ b/internal/db/sql-wasm.go @@ -21,20 +21,18 @@ package db */ import ( - // Core code. - _ "github.com/ncruces/go-sqlite3" - // Driver code. - _ "github.com/ncruces/go-sqlite3/driver" // Embedded SQLite instance. _ "github.com/ncruces/go-sqlite3/embed" + "github.com/ncruces/go-sqlite3/gormlite" + // Register the pure-Go adiantum VFS so an encryption key in the DSN + // (vfs=adiantum&textkey=...) transparently encrypts the database at rest. + _ "github.com/ncruces/go-sqlite3/vfs/adiantum" "gorm.io/gorm" "gorm.io/gorm/logger" - - "github.com/reeflective/team/internal/db/wasmsqlite" ) func sqliteClient(dsn string, log logger.Interface) (*gorm.DB, error) { - return gorm.Open(wasmsqlite.Open(dsn), &gorm.Config{ + return gorm.Open(gormlite.Open(dsn), &gorm.Config{ PrepareStmt: true, Logger: log, }) diff --git a/internal/db/sql.go b/internal/db/sql.go index c711784..168d065 100644 --- a/internal/db/sql.go +++ b/internal/db/sql.go @@ -85,6 +85,16 @@ func NewClient(dbConfig *Config, dbLogger *slog.Logger) (*gorm.DB, error) { return nil, fmt.Errorf("%w: '%s'", ErrUnsupportedDialect, dbConfig.Dialect) } + // For SQLite, force an actual page read now so that a wrong encryption key + // (or an otherwise corrupt/unreadable file) surfaces here as a clean error, + // instead of panicking later inside AutoMigrate's schema introspection. + if dbConfig.Dialect == Sqlite { + var count int + if err := dbClient.Raw("SELECT count(*) FROM sqlite_master").Scan(&count).Error; err != nil { + return nil, fmt.Errorf("Database open failed (wrong encryption key or corrupt database?): %w", err) + } + } + err = dbClient.AutoMigrate(Schema()...) if err != nil { dbLogger.Error(err.Error()) diff --git a/internal/db/wasmsqlite/License b/internal/db/wasmsqlite/License deleted file mode 100644 index 037e165..0000000 --- a/internal/db/wasmsqlite/License +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013-NOW Jinzhu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/internal/db/wasmsqlite/README.md b/internal/db/wasmsqlite/README.md deleted file mode 100644 index 7ffa285..0000000 --- a/internal/db/wasmsqlite/README.md +++ /dev/null @@ -1,56 +0,0 @@ -![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/glebarez/fb4d23f63d866b3e1e58b26d2f5ed01f/raw/badge-gorm-tests.json) -![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/glebarez/fb4d23f63d866b3e1e58b26d2f5ed01f/raw/badge-sqlite-version.json) -
[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fglebarez%2Fsqlite&count_bg=%2379C83D&title_bg=%23555555&icon=baidu.svg&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) -# Pure-Go SQLite driver for GORM -Pure-go (without cgo) implementation of SQLite driver for [GORM](https://gorm.io/)

-This driver has SQLite embedded, you don't need to install one separately. - -# Usage - -```go -import ( - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -db, err := gorm.Open(sqlite.Open("sqlite.db"), &gorm.Config{}) -``` - -### In-memory DB example -```go -db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) -``` - -### Foreign-key constraint activation -Foreign-key constraint is disabled by default in SQLite. To activate it, use connection URL parameter: -```go -db, err := gorm.Open(sqlite.Open(":memory:?_pragma=foreign_keys(1)"), &gorm.Config{}) -``` -More info: [https://www.sqlite.org/foreignkeys.html](https://www.sqlite.org/foreignkeys.html) - -# FAQ -## How is this better than standard GORM SQLite driver? -The [standard GORM driver for SQLite](https://github.com/go-gorm/sqlite) has one major drawback: it is based on a [Go-bindings of SQLite C-source](https://github.com/mattn/go-sqlite3) (this is called [cgo](https://go.dev/blog/cgo)). This fact imposes following restrictions on Go developers: -- to build and run your code, you will need a C compiler installed on a machine -- SQLite has many features that need to be enabled at compile time (e.g. [json support](https://www.sqlite.org/json1.html)). If you plan to use those, you will have to include proper build tags for every ```go``` command to work properly (```go run```, ```go test```, etc.). -- Because of C-compiler requirement, you can't build your Go code inside tiny stripped containers like (golang-alpine) -- Building on GCP is not possible because Google Cloud Platform does not allow gcc to be executed. - -**Instead**, this driver is based on pure-Go implementation of SQLite (https://gitlab.com/cznic/sqlite), which is basically an original SQLite C-source AST, translated into Go! So, you may be sure you're using the original SQLite implementation under the hood. - -## Is this tested good ? -Yes, The CI pipeline of this driver employs [whole test base](https://github.com/go-gorm/gorm/tree/master/tests) of GORM, which includes more than **12k** tests (see badge on the page-top). Testing is run against latest major releases of Go: -- 1.18 -- 1.19 - -In following environments: -- Linux -- Windows -- MacOS - -## Is it fast? -Well, it's slower than CGo implementation, but not terribly. See the [bechmark of underlying pure-Go driver vs CGo implementation](https://github.com/glebarez/go-sqlite/tree/master/benchmark). - -## Included features -- JSON1 (https://www.sqlite.org/json1.html) -- Math functions (https://www.sqlite.org/lang_mathfunc.html) diff --git a/internal/db/wasmsqlite/ddlmod.go b/internal/db/wasmsqlite/ddlmod.go deleted file mode 100644 index 8426080..0000000 --- a/internal/db/wasmsqlite/ddlmod.go +++ /dev/null @@ -1,234 +0,0 @@ -package wasmsqlite - -import ( - "database/sql" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - - "gorm.io/gorm/migrator" -) - -var ( - sqliteSeparator = "`|\"|'|\t" - indexRegexp = regexp.MustCompile(fmt.Sprintf("(?is)CREATE(?: UNIQUE)? INDEX [%v]?[\\w\\d-]+[%v]? ON (.*)$", sqliteSeparator, sqliteSeparator)) - tableRegexp = regexp.MustCompile(fmt.Sprintf("(?is)(CREATE TABLE [%v]?[\\w\\d-]+[%v]?)(?: \\((.*)\\))?", sqliteSeparator, sqliteSeparator)) - separatorRegexp = regexp.MustCompile(fmt.Sprintf("[%v]", sqliteSeparator)) - columnsRegexp = regexp.MustCompile(fmt.Sprintf("\\([%v]?([\\w\\d]+)[%v]?(?:,[%v]?([\\w\\d]+)[%v]){0,}\\)", sqliteSeparator, sqliteSeparator, sqliteSeparator, sqliteSeparator)) - columnRegexp = regexp.MustCompile(fmt.Sprintf("^[%v]?([\\w\\d]+)[%v]?\\s+([\\w\\(\\)\\d]+)(.*)$", sqliteSeparator, sqliteSeparator)) - defaultValueRegexp = regexp.MustCompile("(?i) DEFAULT \\(?(.+)?\\)?( |COLLATE|GENERATED|$)") - regRealDataType = regexp.MustCompile(`[^\d](\d+)[^\d]?`) -) - -type ddl struct { - head string - fields []string - columns []migrator.ColumnType -} - -func parseDDL(strs ...string) (*ddl, error) { - var result ddl - for _, str := range strs { - if sections := tableRegexp.FindStringSubmatch(str); len(sections) > 0 { - var ( - ddlBody = sections[2] - ddlBodyRunes = []rune(ddlBody) - bracketLevel int - quote rune - buf string - ) - ddlBodyRunesLen := len(ddlBodyRunes) - - result.head = sections[1] - - for idx := 0; idx < ddlBodyRunesLen; idx++ { - var ( - next rune = 0 - c = ddlBodyRunes[idx] - ) - if idx+1 < ddlBodyRunesLen { - next = ddlBodyRunes[idx+1] - } - - if sc := string(c); separatorRegexp.MatchString(sc) { - if c == next { - buf += sc // Skip escaped quote - idx++ - } else if quote > 0 { - quote = 0 - } else { - quote = c - } - } else if quote == 0 { - if c == '(' { - bracketLevel++ - } else if c == ')' { - bracketLevel-- - } else if bracketLevel == 0 { - if c == ',' { - result.fields = append(result.fields, strings.TrimSpace(buf)) - buf = "" - continue - } - } - } - - if bracketLevel < 0 { - return nil, errors.New("invalid DDL, unbalanced brackets") - } - - buf += string(c) - } - - if bracketLevel != 0 { - return nil, errors.New("invalid DDL, unbalanced brackets") - } - - if buf != "" { - result.fields = append(result.fields, strings.TrimSpace(buf)) - } - - for _, f := range result.fields { - fUpper := strings.ToUpper(f) - if strings.HasPrefix(fUpper, "CHECK") || - strings.HasPrefix(fUpper, "CONSTRAINT") { - continue - } - - if strings.HasPrefix(fUpper, "PRIMARY KEY") { - matches := columnsRegexp.FindStringSubmatch(f) - if len(matches) > 1 { - for _, name := range matches[1:] { - for idx, column := range result.columns { - if column.NameValue.String == name { - column.PrimaryKeyValue = sql.NullBool{Bool: true, Valid: true} - result.columns[idx] = column - break - } - } - } - } - } else if matches := columnRegexp.FindStringSubmatch(f); len(matches) > 0 { - columnType := migrator.ColumnType{ - NameValue: sql.NullString{String: matches[1], Valid: true}, - DataTypeValue: sql.NullString{String: matches[2], Valid: true}, - ColumnTypeValue: sql.NullString{String: matches[2], Valid: true}, - PrimaryKeyValue: sql.NullBool{Valid: true}, - UniqueValue: sql.NullBool{Valid: true}, - NullableValue: sql.NullBool{Valid: true}, - DefaultValueValue: sql.NullString{Valid: false}, - } - - matchUpper := strings.ToUpper(matches[3]) - if strings.Contains(matchUpper, " NOT NULL") { - columnType.NullableValue = sql.NullBool{Bool: false, Valid: true} - } else if strings.Contains(matchUpper, " NULL") { - columnType.NullableValue = sql.NullBool{Bool: true, Valid: true} - } - if strings.Contains(matchUpper, " UNIQUE") { - columnType.UniqueValue = sql.NullBool{Bool: true, Valid: true} - } - if strings.Contains(matchUpper, " PRIMARY") { - columnType.PrimaryKeyValue = sql.NullBool{Bool: true, Valid: true} - } - if defaultMatches := defaultValueRegexp.FindStringSubmatch(matches[3]); len(defaultMatches) > 1 { - if strings.ToLower(defaultMatches[1]) != "null" { - columnType.DefaultValueValue = sql.NullString{String: strings.Trim(defaultMatches[1], `"`), Valid: true} - } - } - - // data type length - matches := regRealDataType.FindAllStringSubmatch(columnType.DataTypeValue.String, -1) - if len(matches) == 1 && len(matches[0]) == 2 { - size, _ := strconv.Atoi(matches[0][1]) - columnType.LengthValue = sql.NullInt64{Valid: true, Int64: int64(size)} - columnType.DataTypeValue.String = strings.TrimSuffix(columnType.DataTypeValue.String, matches[0][0]) - } - - result.columns = append(result.columns, columnType) - } - } - } else if matches := indexRegexp.FindStringSubmatch(str); len(matches) > 0 { - if columns := columnsRegexp.FindStringSubmatch(matches[1]); len(columns) == 1 { - for idx, c := range result.columns { - if c.NameValue.String == columns[0] { - c.UniqueValue = sql.NullBool{Bool: true, Valid: true} - result.columns[idx] = c - } - } - } - } else { - return nil, errors.New("invalid DDL") - } - } - - return &result, nil -} - -func (d *ddl) compile() string { - if len(d.fields) == 0 { - return d.head - } - - return fmt.Sprintf("%s (%s)", d.head, strings.Join(d.fields, ",")) -} - -func (d *ddl) addConstraint(name string, sql string) { - reg := regexp.MustCompile("^CONSTRAINT [\"`]?" + regexp.QuoteMeta(name) + "[\"` ]") - - for i := 0; i < len(d.fields); i++ { - if reg.MatchString(d.fields[i]) { - d.fields[i] = sql - return - } - } - - d.fields = append(d.fields, sql) -} - -func (d *ddl) removeConstraint(name string) bool { - reg := regexp.MustCompile("^CONSTRAINT [\"`]?" + regexp.QuoteMeta(name) + "[\"` ]") - - for i := 0; i < len(d.fields); i++ { - if reg.MatchString(d.fields[i]) { - d.fields = append(d.fields[:i], d.fields[i+1:]...) - return true - } - } - return false -} - -func (d *ddl) hasConstraint(name string) bool { - reg := regexp.MustCompile("^CONSTRAINT [\"`]?" + regexp.QuoteMeta(name) + "[\"` ]") - - for _, f := range d.fields { - if reg.MatchString(f) { - return true - } - } - return false -} - -func (d *ddl) getColumns() []string { - res := []string{} - - for _, f := range d.fields { - fUpper := strings.ToUpper(f) - if strings.HasPrefix(fUpper, "PRIMARY KEY") || - strings.HasPrefix(fUpper, "CHECK") || - strings.HasPrefix(fUpper, "CONSTRAINT") || - strings.Contains(fUpper, "GENERATED ALWAYS AS") { - continue - } - - reg := regexp.MustCompile("^[\"`']?([\\w\\d]+)[\"`']?") - match := reg.FindStringSubmatch(f) - - if match != nil { - res = append(res, "`"+match[1]+"`") - } - } - return res -} diff --git a/internal/db/wasmsqlite/ddlmod_test.go b/internal/db/wasmsqlite/ddlmod_test.go deleted file mode 100644 index 399c358..0000000 --- a/internal/db/wasmsqlite/ddlmod_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package wasmsqlite - -import ( - "database/sql" - "testing" - - "gorm.io/gorm/migrator" - "gorm.io/gorm/utils/tests" -) - -func TestParseDDL(t *testing.T) { - params := []struct { - name string - sql []string - nFields int - columns []migrator.ColumnType - }{ - {"with_fk", []string{ - "CREATE TABLE `notes` (`id` integer NOT NULL,`text` varchar(500) DEFAULT \"hello\",`age` integer DEFAULT 18,`user_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))", - "CREATE UNIQUE INDEX `idx_profiles_refer` ON `profiles`(`text`)", - }, 6, []migrator.ColumnType{ - {NameValue: sql.NullString{String: "id", Valid: true}, DataTypeValue: sql.NullString{String: "integer", Valid: true}, ColumnTypeValue: sql.NullString{String: "integer", Valid: true}, PrimaryKeyValue: sql.NullBool{Bool: true, Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, DefaultValueValue: sql.NullString{Valid: false}}, - {NameValue: sql.NullString{String: "text", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 500, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(500)", Valid: true}, DefaultValueValue: sql.NullString{String: "hello", Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - {NameValue: sql.NullString{String: "age", Valid: true}, DataTypeValue: sql.NullString{String: "integer", Valid: true}, ColumnTypeValue: sql.NullString{String: "integer", Valid: true}, DefaultValueValue: sql.NullString{String: "18", Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - {NameValue: sql.NullString{String: "user_id", Valid: true}, DataTypeValue: sql.NullString{String: "integer", Valid: true}, ColumnTypeValue: sql.NullString{String: "integer", Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - }, - }, - {"with_check", []string{"CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL,FirstName varchar(255),Age int,CHECK (Age>=18),CHECK (FirstName<>'John'))"}, 6, []migrator.ColumnType{ - {NameValue: sql.NullString{String: "ID", Valid: true}, DataTypeValue: sql.NullString{String: "int", Valid: true}, ColumnTypeValue: sql.NullString{String: "int", Valid: true}, NullableValue: sql.NullBool{Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - {NameValue: sql.NullString{String: "LastName", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 255, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(255)", Valid: true}, NullableValue: sql.NullBool{Bool: false, Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - {NameValue: sql.NullString{String: "FirstName", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 255, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(255)", Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - {NameValue: sql.NullString{String: "Age", Valid: true}, DataTypeValue: sql.NullString{String: "int", Valid: true}, ColumnTypeValue: sql.NullString{String: "int", Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - }}, - {"lowercase", []string{"create table test (ID int NOT NULL)"}, 1, []migrator.ColumnType{ - {NameValue: sql.NullString{String: "ID", Valid: true}, DataTypeValue: sql.NullString{String: "int", Valid: true}, ColumnTypeValue: sql.NullString{String: "int", Valid: true}, NullableValue: sql.NullBool{Bool: false, Valid: true}, DefaultValueValue: sql.NullString{Valid: false}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - }, - }, - {"no brackets", []string{"create table test"}, 0, nil}, - {"with_special_characters", []string{ - "CREATE TABLE `test` (`text` varchar(10) DEFAULT \"测试, \")", - }, 1, []migrator.ColumnType{ - {NameValue: sql.NullString{String: "text", Valid: true}, DataTypeValue: sql.NullString{String: "varchar", Valid: true}, LengthValue: sql.NullInt64{Int64: 10, Valid: true}, ColumnTypeValue: sql.NullString{String: "varchar(10)", Valid: true}, DefaultValueValue: sql.NullString{String: "测试, ", Valid: true}, NullableValue: sql.NullBool{Valid: true}, UniqueValue: sql.NullBool{Valid: true}, PrimaryKeyValue: sql.NullBool{Valid: true}}, - }, - }, - { - "table_name_with_dash", - []string{ - "CREATE TABLE `test-a` (`id` int NOT NULL)", - "CREATE UNIQUE INDEX `idx_test-a_id` ON `test-a`(`id`)", - }, - 1, - []migrator.ColumnType{ - { - NameValue: sql.NullString{String: "id", Valid: true}, - DataTypeValue: sql.NullString{String: "int", Valid: true}, - ColumnTypeValue: sql.NullString{String: "int", Valid: true}, - NullableValue: sql.NullBool{Bool: false, Valid: true}, - DefaultValueValue: sql.NullString{Valid: false}, - UniqueValue: sql.NullBool{Valid: true}, - PrimaryKeyValue: sql.NullBool{Valid: true}, - }, - }, - }, - } - - for _, p := range params { - t.Run(p.name, func(t *testing.T) { - ddl, err := parseDDL(p.sql...) - - if err != nil { - panic(err.Error()) - } - - tests.AssertEqual(t, p.sql[0], ddl.compile()) - if len(ddl.fields) != p.nFields { - t.Fatalf("fields length doesn't match: expect: %v, got %v", p.nFields, len(ddl.fields)) - } - tests.AssertEqual(t, ddl.columns, p.columns) - }) - } -} - -func TestParseDDL_error(t *testing.T) { - params := []struct { - name string - sql string - }{ - {"invalid_cmd", "CREATE TABLE"}, - {"unbalanced_brackets", "CREATE TABLE test (ID int NOT NULL,Name varchar(255)"}, - {"unbalanced_brackets2", "CREATE TABLE test (ID int NOT NULL,Name varchar(255)))"}, - } - - for _, p := range params { - t.Run(p.name, func(t *testing.T) { - _, err := parseDDL(p.sql) - if err == nil { - t.Fail() - } - }) - } -} - -func TestAddConstraint(t *testing.T) { - params := []struct { - name string - fields []string - cName string - sql string - expect []string - }{ - { - name: "add_new", - fields: []string{"`id` integer NOT NULL"}, - cName: "fk_users_notes", - sql: "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))", - expect: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))"}, - }, - { - name: "update", - fields: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))"}, - cName: "fk_users_notes", - sql: "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)) ON UPDATE CASCADE ON DELETE CASCADE", - expect: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)) ON UPDATE CASCADE ON DELETE CASCADE"}, - }, - { - name: "add_check", - fields: []string{"`id` integer NOT NULL"}, - cName: "name_checker", - sql: "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')", - expect: []string{"`id` integer NOT NULL", "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')"}, - }, - { - name: "update_check", - fields: []string{"`id` integer NOT NULL", "CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')"}, - cName: "name_checker", - sql: "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')", - expect: []string{"`id` integer NOT NULL", "CONSTRAINT `name_checker` CHECK (`name` <> 'jinzhu')"}, - }, - } - - for _, p := range params { - t.Run(p.name, func(t *testing.T) { - testDDL := ddl{fields: p.fields} - - testDDL.addConstraint(p.cName, p.sql) - tests.AssertEqual(t, p.expect, testDDL.fields) - }) - } -} - -func TestRemoveConstraint(t *testing.T) { - params := []struct { - name string - fields []string - cName string - success bool - expect []string - }{ - { - name: "fk", - fields: []string{"`id` integer NOT NULL", "CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))"}, - cName: "fk_users_notes", - success: true, - expect: []string{"`id` integer NOT NULL"}, - }, - { - name: "check", - fields: []string{"CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')", "`id` integer NOT NULL"}, - cName: "name_checker", - success: true, - expect: []string{"`id` integer NOT NULL"}, - }, - { - name: "none", - fields: []string{"CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')", "`id` integer NOT NULL"}, - cName: "nothing", - success: false, - expect: []string{"CONSTRAINT `name_checker` CHECK (`name` <> 'thetadev')", "`id` integer NOT NULL"}, - }, - } - - for _, p := range params { - t.Run(p.name, func(t *testing.T) { - testDDL := ddl{fields: p.fields} - - success := testDDL.removeConstraint(p.cName) - - tests.AssertEqual(t, p.success, success) - tests.AssertEqual(t, p.expect, testDDL.fields) - }) - } -} - -func TestGetColumns(t *testing.T) { - params := []struct { - name string - ddl string - columns []string - }{ - { - name: "with_fk", - ddl: "CREATE TABLE `notes` (`id` integer NOT NULL,`text` varchar(500),`user_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_users_notes` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`))", - columns: []string{"`id`", "`text`", "`user_id`"}, - }, - { - name: "with_check", - ddl: "CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL,FirstName varchar(255),Age int,CHECK (Age>=18),CHECK (FirstName!='John'))", - columns: []string{"`ID`", "`LastName`", "`FirstName`", "`Age`"}, - }, - { - name: "with_escaped_quote", - ddl: "CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL DEFAULT \"\",FirstName varchar(255))", - columns: []string{"`ID`", "`LastName`", "`FirstName`"}, - }, - { - name: "with_generated_column", - ddl: "CREATE TABLE Persons (ID int NOT NULL,LastName varchar(255) NOT NULL,FirstName varchar(255),FullName varchar(255) GENERATED ALWAYS AS (FirstName || ' ' || LastName))", - columns: []string{"`ID`", "`LastName`", "`FirstName`"}, - }, - { - name: "with_new_line", - ddl: `CREATE TABLE "tb_sys_role_menu__temp" ( - "id" integer PRIMARY KEY AUTOINCREMENT, - "created_at" datetime NOT NULL, - "updated_at" datetime NOT NULL, - "created_by" integer NOT NULL DEFAULT 0, - "updated_by" integer NOT NULL DEFAULT 0, - "role_id" integer NOT NULL, - "menu_id" bigint NOT NULL -)`, - columns: []string{"`id`", "`created_at`", "`updated_at`", "`created_by`", "`updated_by`", "`role_id`", "`menu_id`"}, - }, - } - - for _, p := range params { - t.Run(p.name, func(t *testing.T) { - testDDL, err := parseDDL(p.ddl) - if err != nil { - panic(err.Error()) - } - - cols := testDDL.getColumns() - - tests.AssertEqual(t, p.columns, cols) - }) - } -} diff --git a/internal/db/wasmsqlite/errors.go b/internal/db/wasmsqlite/errors.go deleted file mode 100644 index cb6c61b..0000000 --- a/internal/db/wasmsqlite/errors.go +++ /dev/null @@ -1,7 +0,0 @@ -package wasmsqlite - -import "errors" - -var ( - ErrConstraintsNotImplemented = errors.New("constraints not implemented on sqlite, consider using DisableForeignKeyConstraintWhenMigrating, more details https://github.com/go-gorm/gorm/wiki/GORM-V2-Release-Note-Draft#all-new-migrator") -) diff --git a/internal/db/wasmsqlite/migrator.go b/internal/db/wasmsqlite/migrator.go deleted file mode 100644 index 0ea6eef..0000000 --- a/internal/db/wasmsqlite/migrator.go +++ /dev/null @@ -1,423 +0,0 @@ -package wasmsqlite - -import ( - "database/sql" - "fmt" - "regexp" - "strings" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - "gorm.io/gorm/migrator" - "gorm.io/gorm/schema" -) - -type Migrator struct { - migrator.Migrator -} - -func (m *Migrator) RunWithoutForeignKey(fc func() error) error { - var enabled int - m.DB.Raw("PRAGMA foreign_keys").Scan(&enabled) - if enabled == 1 { - m.DB.Exec("PRAGMA foreign_keys = OFF") - defer m.DB.Exec("PRAGMA foreign_keys = ON") - } - - return fc() -} - -func (m Migrator) HasTable(value interface{}) bool { - var count int - m.Migrator.RunWithValue(value, func(stmt *gorm.Statement) error { - return m.DB.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", stmt.Table).Row().Scan(&count) - }) - return count > 0 -} - -func (m Migrator) DropTable(values ...interface{}) error { - return m.RunWithoutForeignKey(func() error { - values = m.ReorderModels(values, false) - tx := m.DB.Session(&gorm.Session{}) - - for i := len(values) - 1; i >= 0; i-- { - if err := m.RunWithValue(values[i], func(stmt *gorm.Statement) error { - return tx.Exec("DROP TABLE IF EXISTS ?", clause.Table{Name: stmt.Table}).Error - }); err != nil { - return err - } - } - - return nil - }) -} - -func (m Migrator) GetTables() (tableList []string, err error) { - return tableList, m.DB.Raw("SELECT name FROM sqlite_master where type=?", "table").Scan(&tableList).Error -} - -func (m Migrator) HasColumn(value interface{}, name string) bool { - var count int - m.Migrator.RunWithValue(value, func(stmt *gorm.Statement) error { - if stmt.Schema != nil { - if field := stmt.Schema.LookUpField(name); field != nil { - name = field.DBName - } - } - - if name != "" { - m.DB.Raw( - "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND (sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ?)", - "table", stmt.Table, `%"`+name+`" %`, `%`+name+` %`, "%`"+name+"`%", "%["+name+"]%", "%\t"+name+"\t%", - ).Row().Scan(&count) - } - return nil - }) - return count > 0 -} - -func (m Migrator) AlterColumn(value interface{}, name string) error { - return m.RunWithoutForeignKey(func() error { - return m.recreateTable(value, nil, func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) { - if field := stmt.Schema.LookUpField(name); field != nil { - // lookup field from table definition, ddl might looks like `'name' int,` or `'name' int)` - reg, err := regexp.Compile("(`|'|\"| )" + field.DBName + "(`|'|\"| ) .*?(,|\\)\\s*$)") - if err != nil { - return "", nil, err - } - - createSQL := reg.ReplaceAllString(rawDDL, fmt.Sprintf("`%v` ?$3", field.DBName)) - - if createSQL == rawDDL { - return "", nil, fmt.Errorf("failed to look up field %v from DDL %v", field.DBName, rawDDL) - } - - return createSQL, []interface{}{m.FullDataTypeOf(field)}, nil - } - return "", nil, fmt.Errorf("failed to alter field with name %v", name) - }) - }) -} - -// ColumnTypes return columnTypes []gorm.ColumnType and execErr error -func (m Migrator) ColumnTypes(value interface{}) ([]gorm.ColumnType, error) { - columnTypes := make([]gorm.ColumnType, 0) - execErr := m.RunWithValue(value, func(stmt *gorm.Statement) (err error) { - var ( - sqls []string - sqlDDL *ddl - ) - - if err := m.DB.Raw("SELECT sql FROM sqlite_master WHERE type IN ? AND tbl_name = ? AND sql IS NOT NULL order by type = ? desc", []string{"table", "index"}, stmt.Table, "table").Scan(&sqls).Error; err != nil { - return err - } - - if sqlDDL, err = parseDDL(sqls...); err != nil { - return err - } - - rows, err := m.DB.Session(&gorm.Session{}).Table(stmt.Table).Limit(1).Rows() - if err != nil { - return err - } - defer func() { - err = rows.Close() - }() - - var rawColumnTypes []*sql.ColumnType - rawColumnTypes, err = rows.ColumnTypes() - if err != nil { - return err - } - - for _, c := range rawColumnTypes { - columnType := migrator.ColumnType{SQLColumnType: c} - for _, column := range sqlDDL.columns { - if column.NameValue.String == c.Name() { - column.SQLColumnType = c - columnType = column - break - } - } - columnTypes = append(columnTypes, columnType) - } - - return err - }) - - return columnTypes, execErr -} - -func (m Migrator) DropColumn(value interface{}, name string) error { - return m.recreateTable(value, nil, func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) { - if field := stmt.Schema.LookUpField(name); field != nil { - name = field.DBName - } - - reg, err := regexp.Compile("(`|'|\"| |\\[)" + name + "(`|'|\"| |\\]) .*?,") - if err != nil { - return "", nil, err - } - - createSQL := reg.ReplaceAllString(rawDDL, "") - - return createSQL, nil, nil - }) -} - -func (m Migrator) CreateConstraint(value interface{}, name string) error { - return m.RunWithValue(value, func(stmt *gorm.Statement) error { - constraint, chk, table := m.GuessConstraintAndTable(stmt, name) - - return m.recreateTable(value, &table, - func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) { - var ( - constraintName string - constraintSql string - constraintValues []interface{} - ) - - if constraint != nil { - constraintName = constraint.Name - constraintSql, constraintValues = buildConstraint(constraint) - } else if chk != nil { - constraintName = chk.Name - constraintSql = "CONSTRAINT ? CHECK (?)" - constraintValues = []interface{}{clause.Column{Name: chk.Name}, clause.Expr{SQL: chk.Constraint}} - } else { - return "", nil, nil - } - - createDDL, err := parseDDL(rawDDL) - if err != nil { - return "", nil, err - } - createDDL.addConstraint(constraintName, constraintSql) - createSQL := createDDL.compile() - - return createSQL, constraintValues, nil - }) - }) -} - -func (m Migrator) DropConstraint(value interface{}, name string) error { - return m.RunWithValue(value, func(stmt *gorm.Statement) error { - constraint, chk, table := m.GuessConstraintAndTable(stmt, name) - if constraint != nil { - name = constraint.Name - } else if chk != nil { - name = chk.Name - } - - return m.recreateTable(value, &table, - func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error) { - createDDL, err := parseDDL(rawDDL) - if err != nil { - return "", nil, err - } - createDDL.removeConstraint(name) - createSQL := createDDL.compile() - - return createSQL, nil, nil - }) - }) -} - -func (m Migrator) HasConstraint(value interface{}, name string) bool { - var count int64 - m.RunWithValue(value, func(stmt *gorm.Statement) error { - constraint, chk, table := m.GuessConstraintAndTable(stmt, name) - if constraint != nil { - name = constraint.Name - } else if chk != nil { - name = chk.Name - } - - m.DB.Raw( - "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND (sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ?)", - "table", table, `%CONSTRAINT "`+name+`" %`, `%CONSTRAINT `+name+` %`, "%CONSTRAINT `"+name+"`%", "%CONSTRAINT ["+name+"]%", "%CONSTRAINT \t"+name+"\t%", - ).Row().Scan(&count) - - return nil - }) - - return count > 0 -} - -func (m Migrator) CurrentDatabase() (name string) { - var null interface{} - m.DB.Raw("PRAGMA database_list").Row().Scan(&null, &name, &null) - return -} - -func (m Migrator) BuildIndexOptions(opts []schema.IndexOption, stmt *gorm.Statement) (results []interface{}) { - for _, opt := range opts { - str := stmt.Quote(opt.DBName) - if opt.Expression != "" { - str = opt.Expression - } - - if opt.Collate != "" { - str += " COLLATE " + opt.Collate - } - - if opt.Sort != "" { - str += " " + opt.Sort - } - results = append(results, clause.Expr{SQL: str}) - } - return -} - -func (m Migrator) CreateIndex(value interface{}, name string) error { - return m.RunWithValue(value, func(stmt *gorm.Statement) error { - if idx := stmt.Schema.LookIndex(name); idx != nil { - opts := m.BuildIndexOptions(idx.Fields, stmt) - values := []interface{}{clause.Column{Name: idx.Name}, clause.Table{Name: stmt.Table}, opts} - - createIndexSQL := "CREATE " - if idx.Class != "" { - createIndexSQL += idx.Class + " " - } - createIndexSQL += "INDEX ?" - - if idx.Type != "" { - createIndexSQL += " USING " + idx.Type - } - createIndexSQL += " ON ??" - - if idx.Where != "" { - createIndexSQL += " WHERE " + idx.Where - } - - return m.DB.Exec(createIndexSQL, values...).Error - } - - return fmt.Errorf("failed to create index with name %v", name) - }) -} - -func (m Migrator) HasIndex(value interface{}, name string) bool { - var count int - m.RunWithValue(value, func(stmt *gorm.Statement) error { - if idx := stmt.Schema.LookIndex(name); idx != nil { - name = idx.Name - } - - if name != "" { - m.DB.Raw( - "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "index", stmt.Table, name, - ).Row().Scan(&count) - } - return nil - }) - return count > 0 -} - -func (m Migrator) RenameIndex(value interface{}, oldName, newName string) error { - return m.RunWithValue(value, func(stmt *gorm.Statement) error { - var sql string - m.DB.Raw("SELECT sql FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "index", stmt.Table, oldName).Row().Scan(&sql) - if sql != "" { - return m.DB.Exec(strings.Replace(sql, oldName, newName, 1)).Error - } - return fmt.Errorf("failed to find index with name %v", oldName) - }) -} - -func (m Migrator) DropIndex(value interface{}, name string) error { - return m.RunWithValue(value, func(stmt *gorm.Statement) error { - if idx := stmt.Schema.LookIndex(name); idx != nil { - name = idx.Name - } - - return m.DB.Exec("DROP INDEX ?", clause.Column{Name: name}).Error - }) -} - -func buildConstraint(constraint *schema.Constraint) (sql string, results []interface{}) { - sql = "CONSTRAINT ? FOREIGN KEY ? REFERENCES ??" - if constraint.OnDelete != "" { - sql += " ON DELETE " + constraint.OnDelete - } - - if constraint.OnUpdate != "" { - sql += " ON UPDATE " + constraint.OnUpdate - } - - var foreignKeys, references []interface{} - for _, field := range constraint.ForeignKeys { - foreignKeys = append(foreignKeys, clause.Column{Name: field.DBName}) - } - - for _, field := range constraint.References { - references = append(references, clause.Column{Name: field.DBName}) - } - results = append(results, clause.Table{Name: constraint.Name}, foreignKeys, clause.Table{Name: constraint.ReferenceSchema.Table}, references) - return -} - -func (m Migrator) getRawDDL(table string) (string, error) { - var createSQL string - m.DB.Raw("SELECT sql FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "table", table, table).Row().Scan(&createSQL) - - if m.DB.Error != nil { - return "", m.DB.Error - } - return createSQL, nil -} - -func (m Migrator) recreateTable(value interface{}, tablePtr *string, - getCreateSQL func(rawDDL string, stmt *gorm.Statement) (sql string, sqlArgs []interface{}, err error)) error { - return m.RunWithValue(value, func(stmt *gorm.Statement) error { - table := stmt.Table - if tablePtr != nil { - table = *tablePtr - } - - rawDDL, err := m.getRawDDL(table) - if err != nil { - return err - } - - newTableName := table + "__temp" - - createSQL, sqlArgs, err := getCreateSQL(rawDDL, stmt) - if err != nil { - return err - } - if createSQL == "" { - return nil - } - - tableReg, err := regexp.Compile(" ('|`|\"| )" + table + "('|`|\"| ) ") - if err != nil { - return err - } - createSQL = tableReg.ReplaceAllString(createSQL, fmt.Sprintf(" `%v` ", newTableName)) - - createDDL, err := parseDDL(createSQL) - if err != nil { - return err - } - columns := createDDL.getColumns() - - return m.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Exec(createSQL, sqlArgs...).Error; err != nil { - return err - } - - queries := []string{ - fmt.Sprintf("INSERT INTO `%v`(%v) SELECT %v FROM `%v`", newTableName, strings.Join(columns, ","), strings.Join(columns, ","), table), - fmt.Sprintf("DROP TABLE `%v`", table), - fmt.Sprintf("ALTER TABLE `%v` RENAME TO `%v`", newTableName, table), - } - for _, query := range queries { - if err := tx.Exec(query).Error; err != nil { - return err - } - } - return nil - }) - }) -} diff --git a/internal/db/wasmsqlite/sqlite.go b/internal/db/wasmsqlite/sqlite.go deleted file mode 100644 index 2368ce7..0000000 --- a/internal/db/wasmsqlite/sqlite.go +++ /dev/null @@ -1,224 +0,0 @@ -package wasmsqlite - -import ( - "context" - "database/sql" - "strconv" - "strings" - - "gorm.io/gorm/callbacks" - - _ "github.com/ncruces/go-sqlite3" - _ "github.com/ncruces/go-sqlite3/driver" - _ "github.com/ncruces/go-sqlite3/embed" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - "gorm.io/gorm/logger" - "gorm.io/gorm/migrator" - "gorm.io/gorm/schema" -) - -// DriverName is the default driver name for SQLite. -const DriverName = "sqlite3" - -type Dialector struct { - DriverName string - DSN string - Conn gorm.ConnPool -} - -func Open(dsn string) gorm.Dialector { - return &Dialector{DSN: dsn} -} - -func (dialector Dialector) Name() string { - return "sqlite" -} - -func (dialector Dialector) Initialize(db *gorm.DB) (err error) { - if dialector.DriverName == "" { - dialector.DriverName = DriverName - } - - if dialector.Conn != nil { - db.ConnPool = dialector.Conn - } else { - conn, err := sql.Open(dialector.DriverName, dialector.DSN) - if err != nil { - return err - } - db.ConnPool = conn - } - - var version string - if err := db.ConnPool.QueryRowContext(context.Background(), "select sqlite_version()").Scan(&version); err != nil { - return err - } - // https://www.sqlite.org/releaselog/3_35_0.html - if compareVersion(version, "3.35.0") >= 0 { - callbacks.RegisterDefaultCallbacks(db, &callbacks.Config{ - CreateClauses: []string{"INSERT", "VALUES", "ON CONFLICT", "RETURNING"}, - UpdateClauses: []string{"UPDATE", "SET", "WHERE", "RETURNING"}, - DeleteClauses: []string{"DELETE", "FROM", "WHERE", "RETURNING"}, - LastInsertIDReversed: true, - }) - } else { - callbacks.RegisterDefaultCallbacks(db, &callbacks.Config{ - LastInsertIDReversed: true, - }) - } - - for k, v := range dialector.ClauseBuilders() { - db.ClauseBuilders[k] = v - } - return -} - -func (dialector Dialector) ClauseBuilders() map[string]clause.ClauseBuilder { - return map[string]clause.ClauseBuilder{ - "INSERT": func(c clause.Clause, builder clause.Builder) { - if insert, ok := c.Expression.(clause.Insert); ok { - if stmt, ok := builder.(*gorm.Statement); ok { - stmt.WriteString("INSERT ") - if insert.Modifier != "" { - stmt.WriteString(insert.Modifier) - stmt.WriteByte(' ') - } - - stmt.WriteString("INTO ") - if insert.Table.Name == "" { - stmt.WriteQuoted(stmt.Table) - } else { - stmt.WriteQuoted(insert.Table) - } - return - } - } - - c.Build(builder) - }, - "LIMIT": func(c clause.Clause, builder clause.Builder) { - if limit, ok := c.Expression.(clause.Limit); ok { - var lmt = -1 - if limit.Limit != nil && *limit.Limit >= 0 { - lmt = *limit.Limit - } - if lmt >= 0 || limit.Offset > 0 { - builder.WriteString("LIMIT ") - builder.WriteString(strconv.Itoa(lmt)) - } - if limit.Offset > 0 { - builder.WriteString(" OFFSET ") - builder.WriteString(strconv.Itoa(limit.Offset)) - } - } - }, - "FOR": func(c clause.Clause, builder clause.Builder) { - if _, ok := c.Expression.(clause.Locking); ok { - // SQLite3 does not support row-level locking. - return - } - c.Build(builder) - }, - } -} - -func (dialector Dialector) DefaultValueOf(field *schema.Field) clause.Expression { - if field.AutoIncrement { - return clause.Expr{SQL: "NULL"} - } - - // doesn't work, will raise error - return clause.Expr{SQL: "DEFAULT"} -} - -func (dialector Dialector) Migrator(db *gorm.DB) gorm.Migrator { - return Migrator{migrator.Migrator{Config: migrator.Config{ - DB: db, - Dialector: dialector, - CreateIndexAfterCreateTable: true, - }}} -} - -func (dialector Dialector) BindVarTo(writer clause.Writer, stmt *gorm.Statement, v interface{}) { - writer.WriteByte('?') -} - -func (dialector Dialector) QuoteTo(writer clause.Writer, str string) { - writer.WriteByte('`') - if strings.Contains(str, ".") { - for idx, str := range strings.Split(str, ".") { - if idx > 0 { - writer.WriteString(".`") - } - writer.WriteString(str) - writer.WriteByte('`') - } - } else { - writer.WriteString(str) - writer.WriteByte('`') - } -} - -func (dialector Dialector) Explain(sql string, vars ...interface{}) string { - return logger.ExplainSQL(sql, nil, `"`, vars...) -} - -func (dialector Dialector) DataTypeOf(field *schema.Field) string { - switch field.DataType { - case schema.Bool: - return "numeric" - case schema.Int, schema.Uint: - if field.AutoIncrement && !field.PrimaryKey { - // https://www.sqlite.org/autoinc.html - return "integer PRIMARY KEY AUTOINCREMENT" - } else { - return "integer" - } - case schema.Float: - return "real" - case schema.String: - return "text" - case schema.Time: - return "datetime" - case schema.Bytes: - return "blob" - } - - return string(field.DataType) -} - -func (dialectopr Dialector) SavePoint(tx *gorm.DB, name string) error { - tx.Exec("SAVEPOINT " + name) - return nil -} - -func (dialectopr Dialector) RollbackTo(tx *gorm.DB, name string) error { - tx.Exec("ROLLBACK TO SAVEPOINT " + name) - return nil -} - -func compareVersion(version1, version2 string) int { - n, m := len(version1), len(version2) - i, j := 0, 0 - for i < n || j < m { - x := 0 - for ; i < n && version1[i] != '.'; i++ { - x = x*10 + int(version1[i]-'0') - } - i++ - y := 0 - for ; j < m && version2[j] != '.'; j++ { - y = y*10 + int(version2[j]-'0') - } - j++ - if x > y { - return 1 - } - if x < y { - return -1 - } - } - return 0 -} diff --git a/internal/db/wasmsqlite/sqlite_test.go b/internal/db/wasmsqlite/sqlite_test.go deleted file mode 100644 index 2a6055d..0000000 --- a/internal/db/wasmsqlite/sqlite_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package wasmsqlite - -import ( - "database/sql" - "fmt" - "testing" - - _ "github.com/ncruces/go-sqlite3" - "gorm.io/gorm" - "modernc.org/sqlite" -) - -func TestDialector(t *testing.T) { - // This is the DSN of the in-memory SQLite database for these tests. - const InMemoryDSN = "file:testdatabase?mode=memory&cache=shared" - // This is the custom SQLite driver name. - const CustomDriverName = "my_custom_driver" - - // Register the custom SQlite3 driver. - // It will have one custom function called "my_custom_function". - - sql.Register(CustomDriverName, - &sqlite.Driver{}, - ) - - rows := []struct { - description string - dialector *Dialector - openSuccess bool - query string - querySuccess bool - }{ - { - description: "Default driver", - dialector: &Dialector{ - DSN: InMemoryDSN, - }, - openSuccess: true, - query: "SELECT 1", - querySuccess: true, - }, - { - description: "Explicit default driver", - dialector: &Dialector{ - DriverName: DriverName, - DSN: InMemoryDSN, - }, - openSuccess: true, - query: "SELECT 1", - querySuccess: true, - }, - { - description: "Bad driver", - dialector: &Dialector{ - DriverName: "not-a-real-driver", - DSN: InMemoryDSN, - }, - openSuccess: false, - }, - // { - // description: "Explicit default driver, custom function", - // dialector: &Dialector{ - // DriverName: DriverName, - // DSN: InMemoryDSN, - // }, - // openSuccess: true, - // query: "SELECT my_custom_function()", - // querySuccess: false, - // }, - { - description: "Custom driver", - dialector: &Dialector{ - DriverName: CustomDriverName, - DSN: InMemoryDSN, - }, - openSuccess: true, - query: "SELECT 1", - querySuccess: true, - }, - // { - // description: "Custom driver, custom function", - // dialector: &Dialector{ - // DriverName: CustomDriverName, - // DSN: InMemoryDSN, - // }, - // openSuccess: true, - // query: "SELECT my_custom_function()", - // querySuccess: true, - // }, - } - for rowIndex, row := range rows { - t.Run(fmt.Sprintf("%d/%s", rowIndex, row.description), func(t *testing.T) { - db, err := gorm.Open(row.dialector, &gorm.Config{}) - if !row.openSuccess { - if err == nil { - t.Errorf("Expected Open to fail.") - } - return - } - - if err != nil { - t.Errorf("Expected Open to succeed; got error: %v", err) - } - if db == nil { - t.Errorf("Expected db to be non-nil.") - } - if row.query != "" { - err = db.Exec(row.query).Error - if !row.querySuccess { - if err == nil { - t.Errorf("Expected query to fail.") - } - return - } - - if err != nil { - t.Errorf("Expected query to succeed; got error: %v", err) - } - } - }) - } -} diff --git a/internal/db/wasmsqlite/sqlite_version_test.go b/internal/db/wasmsqlite/sqlite_version_test.go deleted file mode 100644 index 7d8c472..0000000 --- a/internal/db/wasmsqlite/sqlite_version_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package wasmsqlite - -import ( - "database/sql" - "log" - "testing" -) - -func TestSQLiteVersion(t *testing.T) { - var version string - - db, err := sql.Open(DriverName, ":memory:") - if err != nil { - log.Fatal(err) - } - - row := db.QueryRow("select sqlite_version()") - if row.Scan(&version) != nil { - log.Fatal(err) - } - - t.Log(version) -} diff --git a/server/db.go b/server/db.go index 2f55aa2..5f764f8 100644 --- a/server/db.go +++ b/server/db.go @@ -177,6 +177,12 @@ func (ts *Server) initDatabase() (err error) { return } + // Apply an out-of-band encryption key (never persisted to the config + // file) so the on-disk SQLite database is encrypted at rest. + if ts.opts.dbKey != "" { + ts.opts.dbConfig.EncryptionKey = ts.opts.dbKey + } + ts.db, err = db.NewClient(ts.opts.dbConfig, dbLogger) if err != nil { return diff --git a/server/encryption_test.go b/server/encryption_test.go new file mode 100644 index 0000000..6d33bfc --- /dev/null +++ b/server/encryption_test.go @@ -0,0 +1,76 @@ +//go:build !cgo_sqlite + +package server + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "bytes" + "os" + "testing" +) + +// TestDatabaseEncryptionAtRest exercises the public WithDatabaseKey option end +// to end: a file-based teamserver created with a key must produce an on-disk +// database that is encrypted (no SQLite header, no user data in the clear). The +// adiantum VFS used here is pure-Go, so this test excludes the cgo_sqlite build. +func TestDatabaseEncryptionAtRest(t *testing.T) { + const userMarker = "ENCRYPTED_USER_MARKER" + + home := t.TempDir() + + ts, err := New("enctest", + WithHomeDirectory(home), + WithDatabaseKey("correct horse battery staple"), + ) + if err != nil { + t.Fatalf("server.New: %v", err) + } + if err := ts.init(); err != nil { + t.Fatalf("server.init: %v", err) + } + + // Write some recognizable data into the database. + if _, err := ts.UserCreate(userMarker, "localhost", 31337); err != nil { + t.Fatalf("UserCreate: %v", err) + } + + // Resolve the on-disk database path and flush the connection. + dbPath := ts.opts.dbConfig.Database + if dbPath == "" { + t.Fatal("expected a file-based database path") + } + if sqlDB, derr := ts.db.DB(); derr == nil { + sqlDB.Close() + } + + raw, err := os.ReadFile(dbPath) + if err != nil { + t.Fatalf("read database file %q: %v", dbPath, err) + } + if len(raw) == 0 { + t.Fatal("database file is empty") + } + if bytes.HasPrefix(raw, []byte("SQLite format 3")) { + t.Fatal("database is not encrypted: standard SQLite header present") + } + if bytes.Contains(raw, []byte(userMarker)) { + t.Fatal("database is not encrypted: user name found in cleartext on disk") + } +} diff --git a/server/options.go b/server/options.go index 87bbf45..9c64820 100644 --- a/server/options.go +++ b/server/options.go @@ -53,6 +53,7 @@ type opts struct { config *Config dbConfig *db.Config + dbKey string db *gorm.DB logger slog.Handler consoleStyle func(*log.ConsoleOptions) @@ -165,6 +166,26 @@ func WithDatabaseConfig(config *db.Config) Options { } } +// WithDatabaseKey enables transparent encryption-at-rest for the default, +// file-based SQLite database. The provided key is used to derive the encryption +// key (via Argon2id) for the pure-Go adiantum VFS, so it works out of the box on +// the default and wasm_sqlite builds, with no CGO and no external tooling. +// +// The key is never written to disk (in particular, it is not stored in the +// database configuration file): the application is responsible for sourcing it +// securely (environment variable, prompt, KMS, ...) on each start. +// +// This option has no effect on in-memory databases (nothing is persisted), on +// user-provided backends passed with WithDatabase, or on the cgo_sqlite build +// (whose SQLite engine does not provide the adiantum VFS). +// +// This option can only be used once, and must be passed to server.New(). +func WithDatabaseKey(key string) Options { + return func(opts *opts) { + opts.dbKey = key + } +} + // WithHomeDirectory sets the default path (~/.app/) of the application directory. // This path can still be overridden at the user-level with the env var APP_ROOT_DIR. // From 872973c6d2251d0ed19b057d924b135c042cdfbb Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 17:38:24 +0200 Subject: [PATCH 4/6] README: note all listed enhancements have now landed Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7c009d4..90092d9 100644 --- a/README.md +++ b/README.md @@ -375,8 +375,8 @@ The CLI and API are considered mostly stable; they may grow a little but are des and won't shrink. New behavior is expected to arrive through `client.Options` / `server.Options` rather than changes to the teamclient/teamserver types. -The **Possible enhancements** below are roughly one minor release each (`0.1.0`, `0.2.0`, …) toward -`v1.0.0`. +The **Possible enhancements** below were each roughly one minor release (`0.1.0`, `0.2.0`, …) toward +`v1.0.0`, and have now all landed. - Please open an issue or PR for any bug — it will be resolved promptly. - Features and PRs are welcome when they're likely to help most users. From e5310f9e5fce35ba7c9cfa62fc8660da192a7fc6 Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 17:45:44 +0200 Subject: [PATCH 5/6] test: fix Windows TempDir cleanup in server encryption test The server-level encryption test opened a real teamserver, whose log file stayed open for the server's lifetime. On Windows, t.TempDir() cleanup then failed to remove the still-open log file ("the process cannot access the file because it is being used by another process"). Use WithNoLogs(true) to keep the database on disk (needed to verify encryption at rest) while opening no log file. The db-layer encryption test is unaffected: ncruces' VFS opens files allowing delete-while-open. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/encryption_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/encryption_test.go b/server/encryption_test.go index 6d33bfc..17b0608 100644 --- a/server/encryption_test.go +++ b/server/encryption_test.go @@ -35,8 +35,13 @@ func TestDatabaseEncryptionAtRest(t *testing.T) { home := t.TempDir() + // WithNoLogs keeps the database on disk (unlike WithInMemory) while avoiding + // an open log-file handle, which on Windows would block the t.TempDir() + // cleanup ("the process cannot access the file because it is being used by + // another process"). ts, err := New("enctest", WithHomeDirectory(home), + WithNoLogs(true), WithDatabaseKey("correct horse battery staple"), ) if err != nil { From d052c3e7f7bc704bd70527e5a9cd10b92dc8e10a Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 17:51:09 +0200 Subject: [PATCH 6/6] test: actually prevent log-file open in server encryption test (Windows) WithNoLogs(true) does not stop initLogging from opening the log file (noLogs is only consulted by AuditLogger). Inject a discard slog handler via WithLogger instead: initLogging then returns early and opens no log file, so the Windows t.TempDir() cleanup no longer trips over a still-open handle. The database stays on disk (encryption is still exercised) and its file is deletable while open. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/encryption_test.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/encryption_test.go b/server/encryption_test.go index 17b0608..9291fd8 100644 --- a/server/encryption_test.go +++ b/server/encryption_test.go @@ -22,6 +22,8 @@ package server import ( "bytes" + "io" + "log/slog" "os" "testing" ) @@ -35,13 +37,15 @@ func TestDatabaseEncryptionAtRest(t *testing.T) { home := t.TempDir() - // WithNoLogs keeps the database on disk (unlike WithInMemory) while avoiding - // an open log-file handle, which on Windows would block the t.TempDir() - // cleanup ("the process cannot access the file because it is being used by - // another process"). + // Inject a discard log handler so the server opens no log file. That keeps + // the database on disk (unlike WithInMemory) while avoiding a lingering open + // log-file handle, which on Windows would block the t.TempDir() cleanup ("the + // process cannot access the file because it is being used by another + // process"). The SQLite database file itself is deletable while open. + discard := slog.NewTextHandler(io.Discard, nil) ts, err := New("enctest", WithHomeDirectory(home), - WithNoLogs(true), + WithLogger(discard), WithDatabaseKey("correct horse battery staple"), ) if err != nil {