diff --git a/README.md b/README.md index 9e2e11b..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. @@ -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. -- [ ] 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] 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/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.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 new file mode 100644 index 0000000..d04336b --- /dev/null +++ b/internal/db/config_test.go @@ -0,0 +1,200 @@ +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) + } +} + +// 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. +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/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..9291fd8 --- /dev/null +++ b/server/encryption_test.go @@ -0,0 +1,85 @@ +//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" + "io" + "log/slog" + "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() + + // 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), + WithLogger(discard), + 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. // 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) + } + } +}