Commit 2965d74d76

2965d74d76d6fa462d8fe7ca5d59c1f615b89ded

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 22:12 UTC

M0: config validation, schema migrations, CLI skeleton

- forged check-config with static contradiction checks and host probes
- SQLite store with embedded up/down migrations, v1 schema, key_epoch seed
- reserved-name and owner/repo name validation
- forge CLI command tree (stubs), forged serve/migrate/admin entry points

Layout: unified · split

.gitignore added +3
@@ -0,0 +1,3 @@
1/forge
2/forged
3*.db
cmd/forge/main.go added +143
@@ -0,0 +1,143 @@
1// forge is the client CLI. It speaks to a forge server over the system ssh
2// binary; it is ergonomics on top of a control plane that is fully usable
3// from bare OpenSSH.
4package main
5
6import (
7 "fmt"
8 "os"
9
10 "github.com/spf13/cobra"
11
12 "github.com/krazywarez/forge/internal/protocol"
13)
14
15func main() {
16 root := &cobra.Command{
17 Use: "forge",
18 Short: "CLI-first git forge client",
19 SilenceUsage: true,
20 SilenceErrors: true,
21 }
22 root.PersistentFlags().Bool("json", false, "machine-readable output")
23 root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)")
24
25 root.AddCommand(
26 authCmd(),
27 repoCmd(),
28 issueCmd(),
29 mrCmd(),
30 webCmd(),
31 adminCmd(),
32 remoteCmd(),
33 initCmd(),
34 )
35
36 if err := root.Execute(); err != nil {
37 fmt.Fprintln(os.Stderr, "forge:", err)
38 os.Exit(protocol.ExitFailure)
39 }
40}
41
42// stub returns a leaf command that fails until its milestone lands.
43func stub(use, short string) *cobra.Command {
44 return &cobra.Command{
45 Use: use,
46 Short: short,
47 RunE: func(cmd *cobra.Command, args []string) error {
48 return fmt.Errorf("not implemented")
49 },
50 }
51}
52
53func group(use, short string, subs ...*cobra.Command) *cobra.Command {
54 c := &cobra.Command{Use: use, Short: short}
55 c.AddCommand(subs...)
56 return c
57}
58
59func authCmd() *cobra.Command {
60 return group("auth", "identity: keys, emails, whoami",
61 stub("whoami", "show the authenticated account"),
62 group("keys", "manage SSH keys",
63 stub("list", "list registered SSH keys"),
64 stub("add", "register an SSH key"),
65 stub("remove", "remove an SSH key"),
66 ),
67 group("pgp", "manage OpenPGP keys",
68 stub("list", "list registered PGP keys"),
69 stub("add", "register a PGP key"),
70 stub("remove", "remove a PGP key"),
71 ),
72 group("email", "manage email addresses",
73 stub("add", "add an address"),
74 stub("verify", "confirm a verification code"),
75 ),
76 )
77}
78
79func repoCmd() *cobra.Command {
80 return group("repo", "create and manage repositories",
81 stub("create", "create a repository"),
82 stub("list", "list repositories"),
83 stub("show", "show repository details"),
84 stub("clone", "clone via ssh"),
85 stub("rename", "rename a repository"),
86 stub("delete", "delete a repository"),
87 stub("fork", "fork a repository"),
88 stub("import", "server-side mirror from a foreign URL"),
89 stub("settings", "get or set repository settings"),
90 )
91}
92
93func issueCmd() *cobra.Command {
94 return group("issue", "issues",
95 stub("create", "open an issue"),
96 stub("list", "list issues"),
97 stub("show", "show an issue"),
98 stub("comment", "comment on an issue"),
99 stub("close", "close an issue"),
100 stub("reopen", "reopen an issue"),
101 stub("label", "add or remove labels"),
102 stub("assign", "assign users"),
103 )
104}
105
106func mrCmd() *cobra.Command {
107 return group("mr", "merge requests",
108 stub("create", "open a merge request"),
109 stub("list", "list merge requests"),
110 stub("show", "show a merge request"),
111 stub("diff", "show the diff"),
112 stub("checkout", "fetch and check out the MR head locally"),
113 stub("comment", "comment on a merge request"),
114 stub("review", "approve or request changes"),
115 stub("merge", "merge (fast-forward or merge-commit)"),
116 stub("close", "close without merging"),
117 )
118}
119
120func webCmd() *cobra.Command {
121 return group("web", "browser session",
122 stub("login", "mint a one-time browser login URL over ssh"),
123 )
124}
125
126func adminCmd() *cobra.Command {
127 return group("admin", "instance administration (admin accounts only)",
128 stub("user", "manage users"),
129 stub("invite", "issue registration invites"),
130 stub("stats", "instance statistics"),
131 )
132}
133
134func remoteCmd() *cobra.Command {
135 return group("remote", "local instance profiles (no server contact)",
136 stub("add", "add a named forge instance"),
137 stub("list", "list configured instances"),
138 )
139}
140
141func initCmd() *cobra.Command {
142 return stub("init", "git init + repo create + set origin, in one step")
143}
cmd/forged/main.go added +125
@@ -0,0 +1,125 @@
1// forged is the forge server daemon. The same binary also runs in hook mode
2// (invoked by git via core.hooksPath) and hosts the host-local admin commands.
3package main
4
5import (
6 "fmt"
7 "os"
8
9 "github.com/spf13/cobra"
10
11 "github.com/krazywarez/forge/internal/config"
12 "github.com/krazywarez/forge/internal/store"
13)
14
15var configPath string
16
17func main() {
18 root := &cobra.Command{
19 Use: "forged",
20 Short: "forge server daemon",
21 SilenceUsage: true,
22 SilenceErrors: true,
23 }
24 root.PersistentFlags().StringVar(&configPath, "config", "/etc/forge/config.toml", "path to config file")
25
26 root.AddCommand(
27 checkConfigCmd(),
28 serveCmd(),
29 migrateCmd(),
30 adminCmd(),
31 )
32
33 if err := root.Execute(); err != nil {
34 fmt.Fprintln(os.Stderr, "forged:", err)
35 os.Exit(1)
36 }
37}
38
39func checkConfigCmd() *cobra.Command {
40 var noHost bool
41 cmd := &cobra.Command{
42 Use: "check-config",
43 Short: "validate the configuration and exit",
44 RunE: func(cmd *cobra.Command, args []string) error {
45 cfg, err := config.Load(configPath)
46 if err != nil {
47 return err
48 }
49 if !noHost {
50 if err := cfg.CheckHost(); err != nil {
51 return err
52 }
53 }
54 fmt.Println("config ok")
55 return nil
56 },
57 }
58 cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)")
59 return cmd
60}
61
62func serveCmd() *cobra.Command {
63 return &cobra.Command{
64 Use: "serve",
65 Short: "run the ssh, http, and git listeners",
66 RunE: func(cmd *cobra.Command, args []string) error {
67 return fmt.Errorf("not implemented (M1)")
68 },
69 }
70}
71
72func migrateCmd() *cobra.Command {
73 var to int
74 cmd := &cobra.Command{
75 Use: "migrate",
76 Short: "apply schema migrations",
77 RunE: func(cmd *cobra.Command, args []string) error {
78 cfg, err := config.Load(configPath)
79 if err != nil {
80 return err
81 }
82 s, err := store.Open(cfg.Server.Root + "/forge.db")
83 if err != nil {
84 return err
85 }
86 defer s.Close()
87 if err := s.MigrateTo(to); err != nil {
88 return err
89 }
90 v, err := s.Version()
91 if err != nil {
92 return err
93 }
94 fmt.Println("schema version", v)
95 return nil
96 },
97 }
98 cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
99 return cmd
100}
101
102func adminCmd() *cobra.Command {
103 admin := &cobra.Command{
104 Use: "admin",
105 Short: "host-local administration",
106 }
107 notImplemented := func(use, short string) *cobra.Command {
108 return &cobra.Command{
109 Use: use,
110 Short: short,
111 RunE: func(cmd *cobra.Command, args []string) error {
112 return fmt.Errorf("not implemented (M1)")
113 },
114 }
115 }
116 admin.AddCommand(
117 notImplemented("user", "create and manage users"),
118 notImplemented("invite", "issue registration invites"),
119 notImplemented("email", "verify user emails"),
120 notImplemented("backup", "consistent backup: repos first, then database"),
121 notImplemented("gc", "run git gc across repositories"),
122 notImplemented("stats", "instance statistics"),
123 )
124 return admin
125}
go.mod added +23
@@ -0,0 +1,23 @@
1module github.com/krazywarez/forge
2
3go 1.27.0
4
5require (
6 github.com/BurntSushi/toml v1.6.0
7 github.com/spf13/cobra v1.10.2
8 modernc.org/sqlite v1.57.0
9)
10
11require (
12 github.com/dustin/go-humanize v1.0.1 // indirect
13 github.com/google/uuid v1.6.0 // indirect
14 github.com/inconshreveable/mousetrap v1.1.0 // indirect
15 github.com/mattn/go-isatty v0.0.24 // indirect
16 github.com/ncruces/go-strftime v1.0.0 // indirect
17 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
18 github.com/spf13/pflag v1.0.9 // indirect
19 golang.org/x/sys v0.47.0 // indirect
20 modernc.org/libc v1.74.4 // indirect
21 modernc.org/mathutil v1.7.1 // indirect
22 modernc.org/memory v1.11.0 // indirect
23)
go.sum added +62
@@ -0,0 +1,62 @@
1github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
5github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
6github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
7github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
8github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
9github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
10github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
11github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
12github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
13github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
14github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
15github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
16github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
17github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
18github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
19github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
20github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
21github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
22github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
23github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
24github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
25go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
26golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
27golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
28golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
29golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
30golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
31golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
32golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
33golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
34gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
35modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
36modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
37modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
38modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
39modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
40modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
41modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
42modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
43modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
44modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
45modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
46modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
47modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
48modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
49modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
50modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
51modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
52modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
53modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
54modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
55modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
56modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
57modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
58modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
59modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
60modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
61modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
62modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
internal/config/config.go added +176
@@ -0,0 +1,176 @@
1// Package config loads and validates the forged server configuration.
2package config
3
4import (
5 "errors"
6 "fmt"
7 "net"
8 "os"
9 "strconv"
10
11 "github.com/BurntSushi/toml"
12)
13
14type Config struct {
15 Server Server `toml:"server"`
16 SSH SSH `toml:"ssh"`
17 HTTP HTTP `toml:"http"`
18 GitDaemon GitDaemon `toml:"git_daemon"`
19 Web Web `toml:"web"`
20 Registration Registration `toml:"registration"`
21 Limits Limits `toml:"limits"`
22 Mail Mail `toml:"mail"`
23}
24
25type Server struct {
26 Root string `toml:"root"`
27 SiteURL string `toml:"site_url"`
28}
29
30type SSH struct {
31 Mode string `toml:"mode"` // embedded | system
32 Port int `toml:"port"`
33 HostKeys []string `toml:"host_keys"`
34}
35
36type HTTP struct {
37 Addr string `toml:"addr"`
38 TLS string `toml:"tls"` // acme | files | off
39}
40
41type GitDaemon struct {
42 Enabled bool `toml:"enabled"`
43 Port int `toml:"port"`
44}
45
46type Web struct {
47 Mode string `toml:"mode"` // view_only | accounts
48 PasswordAuth bool `toml:"password_auth"`
49}
50
51type Registration struct {
52 Mode string `toml:"mode"` // closed | invite | open
53}
54
55type Limits struct {
56 MaxPackBytes int64 `toml:"max_pack_bytes"`
57 MaxBlobBytes int64 `toml:"max_blob_bytes"`
58 CloneTimeoutSec int `toml:"clone_timeout"`
59 SSHAuthRate int `toml:"ssh_auth_rate"`
60}
61
62type Mail struct {
63 SMTPHost string `toml:"smtp_host"`
64 From string `toml:"from"`
65}
66
67// Default returns the configuration used when a key is absent from the file.
68func Default() Config {
69 return Config{
70 Server: Server{Root: "/var/lib/forge"},
71 SSH: SSH{Mode: "embedded", Port: 22},
72 HTTP: HTTP{Addr: ":443", TLS: "acme"},
73 Web: Web{Mode: "view_only"},
74 Registration: Registration{
75 Mode: "closed",
76 },
77 GitDaemon: GitDaemon{Port: 9418},
78 Limits: Limits{
79 MaxPackBytes: 2 << 30, // 2 GiB
80 MaxBlobBytes: 100 << 20,
81 CloneTimeoutSec: 3600,
82 SSHAuthRate: 10,
83 },
84 }
85}
86
87// Load reads path, applies defaults, and validates. It does not probe the
88// host (see CheckHost) so it is safe in tests and on non-target machines.
89func Load(path string) (Config, error) {
90 cfg := Default()
91 md, err := toml.DecodeFile(path, &cfg)
92 if err != nil {
93 return cfg, err
94 }
95 if u := md.Undecoded(); len(u) > 0 {
96 return cfg, fmt.Errorf("unknown config key %q", u[0].String())
97 }
98 return cfg, cfg.Validate()
99}
100
101func oneOf(field, val string, allowed ...string) error {
102 for _, a := range allowed {
103 if val == a {
104 return nil
105 }
106 }
107 return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val)
108}
109
110// Validate applies the static contradiction checks from the plan.
111func (c Config) Validate() error {
112 var errs []error
113
114 if c.Server.Root == "" {
115 errs = append(errs, errors.New("server.root is required"))
116 }
117 if c.Server.SiteURL == "" {
118 errs = append(errs, errors.New("server.site_url is required"))
119 }
120 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
121 errs = append(errs, err)
122 }
123 if c.SSH.Port < 1 || c.SSH.Port > 65535 {
124 errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
125 }
126 if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
127 errs = append(errs, err)
128 }
129 if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
130 errs = append(errs, err)
131 }
132 if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil {
133 errs = append(errs, err)
134 }
135
136 // Contradictions.
137 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
138 errs = append(errs, fmt.Errorf(
139 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
140 c.Registration.Mode))
141 }
142 if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
143 errs = append(errs, fmt.Errorf(
144 "ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
145 }
146 if c.Web.PasswordAuth && c.Web.Mode == "view_only" {
147 errs = append(errs, errors.New(
148 "web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
149 }
150
151 return errors.Join(errs...)
152}
153
154// CheckHost performs environment probes that only make sense on the target
155// machine: port availability for the embedded listener and root existence.
156func (c Config) CheckHost() error {
157 var errs []error
158
159 if st, err := os.Stat(c.Server.Root); err != nil {
160 errs = append(errs, fmt.Errorf("server.root: %w", err))
161 } else if !st.IsDir() {
162 errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root))
163 }
164
165 if c.SSH.Mode == "embedded" {
166 addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port))
167 ln, err := net.Listen("tcp", addr)
168 if err != nil {
169 errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err))
170 } else {
171 ln.Close()
172 }
173 }
174
175 return errors.Join(errs...)
176}
internal/config/config_test.go added +121
@@ -0,0 +1,121 @@
1package config
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func writeConfig(t *testing.T, body string) string {
11 t.Helper()
12 p := filepath.Join(t.TempDir(), "config.toml")
13 if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
14 t.Fatal(err)
15 }
16 return p
17}
18
19const minimal = `
20[server]
21root = "/var/lib/forge"
22site_url = "https://forge.example"
23`
24
25func TestLoadMinimal(t *testing.T) {
26 cfg, err := Load(writeConfig(t, minimal))
27 if err != nil {
28 t.Fatal(err)
29 }
30 // Defaults applied.
31 if cfg.SSH.Mode != "embedded" || cfg.SSH.Port != 22 {
32 t.Errorf("ssh defaults wrong: %+v", cfg.SSH)
33 }
34 if cfg.Web.Mode != "view_only" {
35 t.Errorf("web default wrong: %+v", cfg.Web)
36 }
37 if cfg.Registration.Mode != "closed" {
38 t.Errorf("registration default wrong: %+v", cfg.Registration)
39 }
40}
41
42func TestContradictions(t *testing.T) {
43 cases := []struct {
44 name string
45 body string
46 wantErr string
47 }{
48 {
49 "registration open without smtp",
50 minimal + "\n[registration]\nmode = \"open\"\n",
51 "requires [mail] smtp_host",
52 },
53 {
54 "system ssh with open registration",
55 minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"forge@example\"\n",
56 "requires registration.mode = \"closed\"",
57 },
58 {
59 "password auth in view_only",
60 minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n",
61 "password_auth",
62 },
63 {
64 "bad ssh mode",
65 minimal + "\n[ssh]\nmode = \"tcp\"\n",
66 "ssh.mode",
67 },
68 {
69 "unknown key",
70 "[server]\nroot = \"/var/lib/forge\"\nsite_url = \"https://forge.example\"\nbogus = 1\n",
71 "unknown config key",
72 },
73 {
74 "missing site_url",
75 "[server]\nroot = \"/var/lib/forge\"\n",
76 "site_url",
77 },
78 }
79 for _, tc := range cases {
80 t.Run(tc.name, func(t *testing.T) {
81 _, err := Load(writeConfig(t, tc.body))
82 if err == nil {
83 t.Fatalf("expected error containing %q, got nil", tc.wantErr)
84 }
85 if !strings.Contains(err.Error(), tc.wantErr) {
86 t.Fatalf("error %q does not contain %q", err, tc.wantErr)
87 }
88 })
89 }
90}
91
92func TestValidCombinations(t *testing.T) {
93 cases := []struct {
94 name string
95 body string
96 }{
97 {
98 "invite with smtp",
99 minimal + "\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"forge@example\"\n",
100 },
101 {
102 "system ssh closed registration",
103 minimal + "\n[ssh]\nmode = \"system\"\n",
104 },
105 {
106 "accounts web with password auth",
107 minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
108 },
109 {
110 "closed registration, no smtp at all",
111 minimal,
112 },
113 }
114 for _, tc := range cases {
115 t.Run(tc.name, func(t *testing.T) {
116 if _, err := Load(writeConfig(t, tc.body)); err != nil {
117 t.Fatal(err)
118 }
119 })
120 }
121}
internal/policy/names.go added +60
@@ -0,0 +1,60 @@
1// Package policy holds access-control and naming rules.
2package policy
3
4import (
5 "fmt"
6 "regexp"
7)
8
9// reservedNames are forbidden as usernames and org names because they are, or
10// will be, top-level web routes (the UI serves /<owner>/<name>). Any change to
11// the httpd mux's top-level routes must be reflected here; the httpd package
12// asserts this in its tests.
13var reservedNames = map[string]bool{
14 "admin": true,
15 "api": true,
16 "archive": true,
17 "explore": true,
18 "login": true,
19 "logout": true,
20 "new": true,
21 "raw": true,
22 "register": true,
23 "settings": true,
24 "static": true,
25}
26
27// namePat matches valid user, org, and repo names: lowercase alphanumerics,
28// dot, dash, underscore; must start with an alphanumeric. Dots are further
29// restricted by ValidateName to avoid "." / ".." and ".git" suffixes.
30var namePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
31
32// ValidateOwnerName checks a username or org name.
33func ValidateOwnerName(name string) error {
34 if err := ValidateName(name); err != nil {
35 return err
36 }
37 if reservedNames[name] {
38 return fmt.Errorf("name %q is reserved", name)
39 }
40 return nil
41}
42
43// ValidateName checks a repo name (reserved words are allowed for repos;
44// routes are namespaced under the owner).
45func ValidateName(name string) error {
46 if !namePat.MatchString(name) {
47 return fmt.Errorf("invalid name %q: lowercase letters, digits, '.', '-', '_' only; must start with a letter or digit; max 63 chars", name)
48 }
49 if name == "." || name == ".." {
50 return fmt.Errorf("invalid name %q", name)
51 }
52 if len(name) > 4 && name[len(name)-4:] == ".git" {
53 return fmt.Errorf("invalid name %q: must not end in .git", name)
54 }
55 return nil
56}
57
58// Reserved reports whether name is a reserved route word. Exported so the
59// httpd tests can assert route/reserved-list agreement.
60func Reserved(name string) bool { return reservedNames[name] }
internal/policy/names_test.go added +42
@@ -0,0 +1,42 @@
1package policy
2
3import "testing"
4
5func TestValidateOwnerName(t *testing.T) {
6 valid := []string{"alice", "krz", "a", "user-1", "a.b_c", "0day"}
7 for _, n := range valid {
8 if err := ValidateOwnerName(n); err != nil {
9 t.Errorf("ValidateOwnerName(%q) = %v, want nil", n, err)
10 }
11 }
12
13 invalid := []string{
14 "",
15 "Alice", // uppercase
16 "-lead", // bad first char
17 ".hidden", // bad first char
18 "a b", // space
19 "repo.git", // .git suffix
20 "..", //
21 "login", // reserved
22 "admin", // reserved
23 "static", // reserved
24 "api", // reserved
25 "register", // reserved
26 }
27 for _, n := range invalid {
28 if err := ValidateOwnerName(n); err == nil {
29 t.Errorf("ValidateOwnerName(%q) = nil, want error", n)
30 }
31 }
32}
33
34func TestRepoNameAllowsReservedWords(t *testing.T) {
35 // Repo routes are namespaced under the owner, so reserved words are fine.
36 if err := ValidateName("api"); err != nil {
37 t.Errorf("ValidateName(\"api\") = %v, want nil", err)
38 }
39 if err := ValidateName("repo.git"); err == nil {
40 t.Error("ValidateName(\"repo.git\") = nil, want error")
41 }
42}
internal/protocol/protocol.go added +26
@@ -0,0 +1,26 @@
1// Package protocol defines the wire contract shared by the CLI and server:
2// exit codes, the JSON response envelope, and (later) the SSH command
3// tokenizer.
4package protocol
5
6// Version is the control-plane protocol version. It is embedded in every JSON
7// response envelope; clients check it opportunistically and refuse on major
8// mismatch. Bump the major on breaking envelope or command changes.
9const Version = 1
10
11// Exit codes shared by the CLI and by control commands run over bare ssh.
12const (
13 ExitOK = 0
14 ExitFailure = 1 // general failure
15 ExitUsage = 2 // usage error
16 ExitNotFound = 3
17 ExitDenied = 4
18 ExitProtocol = 5 // server/protocol error
19)
20
21// Envelope wraps every JSON response from a control command.
22type Envelope struct {
23 ProtocolVersion int `json:"protocol_version"`
24 Data any `json:"data,omitempty"`
25 Error string `json:"error,omitempty"`
26}
internal/store/migrations/0001_init.down.sql added +22
@@ -0,0 +1,22 @@
1DROP TABLE settings;
2DROP TABLE invites;
3DROP TABLE web_sessions;
4DROP TABLE audit_log;
5DROP TABLE events;
6DROP TABLE commit_signatures;
7DROP TABLE mr_reviews;
8DROP TABLE mr_comments;
9DROP TABLE merge_requests;
10DROP TABLE issue_assignees;
11DROP TABLE issue_labels;
12DROP TABLE labels;
13DROP TABLE issue_comments;
14DROP TABLE issues;
15DROP TABLE repo_access;
16DROP TABLE repos;
17DROP TABLE org_members;
18DROP TABLE orgs;
19DROP TABLE pgp_keys;
20DROP TABLE ssh_keys;
21DROP TABLE emails;
22DROP TABLE users;
internal/store/migrations/0001_init.up.sql added +210
@@ -0,0 +1,210 @@
1CREATE TABLE users (
2 id INTEGER PRIMARY KEY,
3 username TEXT NOT NULL UNIQUE,
4 is_admin INTEGER NOT NULL DEFAULT 0,
5 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
6);
7
8CREATE TABLE emails (
9 id INTEGER PRIMARY KEY,
10 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 address TEXT NOT NULL UNIQUE,
12 verified_at TEXT,
13 verified_by TEXT CHECK (verified_by IN ('smtp','admin')),
14 is_primary INTEGER NOT NULL DEFAULT 0,
15 CHECK ((verified_at IS NULL) = (verified_by IS NULL))
16);
17CREATE INDEX emails_user ON emails(user_id);
18
19CREATE TABLE ssh_keys (
20 id INTEGER PRIMARY KEY,
21 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
22 fingerprint TEXT NOT NULL UNIQUE,
23 algo TEXT NOT NULL,
24 blob BLOB NOT NULL,
25 scope TEXT NOT NULL DEFAULT 'full',
26 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
27 last_used_at TEXT
28);
29CREATE INDEX ssh_keys_user ON ssh_keys(user_id);
30
31CREATE TABLE pgp_keys (
32 id INTEGER PRIMARY KEY,
33 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
34 fingerprint TEXT NOT NULL UNIQUE,
35 armored TEXT NOT NULL,
36 uids_json TEXT NOT NULL DEFAULT '[]',
37 expires_at TEXT,
38 revoked_at TEXT,
39 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
40);
41CREATE INDEX pgp_keys_user ON pgp_keys(user_id);
42
43CREATE TABLE orgs (
44 id INTEGER PRIMARY KEY,
45 name TEXT NOT NULL UNIQUE,
46 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
47);
48
49CREATE TABLE org_members (
50 org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
51 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
52 role TEXT NOT NULL CHECK (role IN ('member','admin')),
53 PRIMARY KEY (org_id, user_id)
54);
55
56CREATE TABLE repos (
57 id INTEGER PRIMARY KEY,
58 owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','org')),
59 owner_id INTEGER NOT NULL,
60 name TEXT NOT NULL,
61 visibility TEXT NOT NULL CHECK (visibility IN ('public','private')),
62 default_branch TEXT NOT NULL DEFAULT 'main',
63 fork_of INTEGER REFERENCES repos(id) ON DELETE SET NULL,
64 issue_counter INTEGER NOT NULL DEFAULT 0,
65 mr_counter INTEGER NOT NULL DEFAULT 0,
66 settings_json TEXT NOT NULL DEFAULT '{}',
67 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
68 UNIQUE (owner_kind, owner_id, name)
69);
70
71CREATE TABLE repo_access (
72 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
73 subject_kind TEXT NOT NULL CHECK (subject_kind IN ('user','org')),
74 subject_id INTEGER NOT NULL,
75 role TEXT NOT NULL CHECK (role IN ('read','write','admin')),
76 PRIMARY KEY (repo_id, subject_kind, subject_id)
77);
78
79CREATE TABLE issues (
80 id INTEGER PRIMARY KEY,
81 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
82 number INTEGER NOT NULL,
83 author_id INTEGER NOT NULL REFERENCES users(id),
84 title TEXT NOT NULL,
85 body TEXT NOT NULL DEFAULT '',
86 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
87 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
88 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
89 UNIQUE (repo_id, number)
90);
91
92CREATE TABLE issue_comments (
93 id INTEGER PRIMARY KEY,
94 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
95 author_id INTEGER NOT NULL REFERENCES users(id),
96 body TEXT NOT NULL,
97 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
98);
99CREATE INDEX issue_comments_issue ON issue_comments(issue_id);
100
101CREATE TABLE labels (
102 id INTEGER PRIMARY KEY,
103 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
104 name TEXT NOT NULL,
105 color TEXT NOT NULL DEFAULT '',
106 UNIQUE (repo_id, name)
107);
108
109CREATE TABLE issue_labels (
110 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
111 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
112 PRIMARY KEY (issue_id, label_id)
113);
114
115CREATE TABLE issue_assignees (
116 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
117 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
118 PRIMARY KEY (issue_id, user_id)
119);
120
121CREATE TABLE merge_requests (
122 id INTEGER PRIMARY KEY,
123 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
124 number INTEGER NOT NULL,
125 author_id INTEGER NOT NULL REFERENCES users(id),
126 source_repo_id INTEGER REFERENCES repos(id) ON DELETE SET NULL,
127 source_ref TEXT NOT NULL,
128 target_ref TEXT NOT NULL,
129 title TEXT NOT NULL,
130 body TEXT NOT NULL DEFAULT '',
131 state TEXT NOT NULL DEFAULT 'open'
132 CHECK (state IN ('open','merged','closed','source_gone')),
133 head_sha TEXT NOT NULL DEFAULT '',
134 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
135 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
136 UNIQUE (repo_id, number)
137);
138
139CREATE TABLE mr_comments (
140 id INTEGER PRIMARY KEY,
141 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
142 author_id INTEGER NOT NULL REFERENCES users(id),
143 body TEXT NOT NULL,
144 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
145);
146CREATE INDEX mr_comments_mr ON mr_comments(mr_id);
147
148CREATE TABLE mr_reviews (
149 id INTEGER PRIMARY KEY,
150 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
151 reviewer_id INTEGER NOT NULL REFERENCES users(id),
152 verdict TEXT NOT NULL CHECK (verdict IN ('approve','request_changes','comment')),
153 head_sha TEXT NOT NULL,
154 stale INTEGER NOT NULL DEFAULT 0,
155 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
156);
157CREATE INDEX mr_reviews_mr ON mr_reviews(mr_id);
158
159CREATE TABLE commit_signatures (
160 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
161 commit_sha TEXT NOT NULL,
162 state TEXT NOT NULL CHECK (state IN (
163 'verified','signed_unknown_key','signed_email_mismatch',
164 'signed_key_expired','signed_key_revoked',
165 'bad_signature','unsigned')),
166 signer_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
167 key_fingerprint TEXT,
168 key_epoch INTEGER NOT NULL,
169 checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
170 PRIMARY KEY (repo_id, commit_sha)
171);
172CREATE INDEX commit_signatures_fpr ON commit_signatures(key_fingerprint);
173
174CREATE TABLE events (
175 id INTEGER PRIMARY KEY,
176 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
177 actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
178 kind TEXT NOT NULL,
179 data_json TEXT NOT NULL DEFAULT '{}',
180 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
181);
182CREATE INDEX events_repo ON events(repo_id, id);
183
184CREATE TABLE audit_log (
185 id INTEGER PRIMARY KEY,
186 actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
187 action TEXT NOT NULL,
188 data_json TEXT NOT NULL DEFAULT '{}',
189 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
190);
191
192CREATE TABLE web_sessions (
193 token_hash TEXT PRIMARY KEY,
194 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
195 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
196 expires_at TEXT NOT NULL
197);
198
199CREATE TABLE invites (
200 code_hash TEXT PRIMARY KEY,
201 email TEXT NOT NULL,
202 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
203 used_at TEXT
204);
205
206CREATE TABLE settings (
207 key TEXT PRIMARY KEY,
208 value TEXT NOT NULL
209);
210INSERT INTO settings (key, value) VALUES ('key_epoch', '1');
internal/store/store.go added +167
@@ -0,0 +1,167 @@
1// Package store owns SQLite access and schema migrations.
2package store
3
4import (
5 "database/sql"
6 "embed"
7 "fmt"
8 "io/fs"
9 "sort"
10 "strconv"
11 "strings"
12
13 _ "modernc.org/sqlite"
14)
15
16//go:embed migrations/*.sql
17var migrationFS embed.FS
18
19type Store struct {
20 DB *sql.DB
21}
22
23// Open opens (creating if needed) the database at path with WAL mode and
24// foreign keys enforced. Use ":memory:" in tests.
25func Open(path string) (*Store, error) {
26 dsn := path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"
27 if path == ":memory:" {
28 dsn = ":memory:?_pragma=foreign_keys(ON)"
29 }
30 db, err := sql.Open("sqlite", dsn)
31 if err != nil {
32 return nil, err
33 }
34 if err := db.Ping(); err != nil {
35 db.Close()
36 return nil, err
37 }
38 return &Store{DB: db}, nil
39}
40
41func (s *Store) Close() error { return s.DB.Close() }
42
43type migration struct {
44 version int
45 name string
46 up string
47 down string
48}
49
50func loadMigrations() ([]migration, error) {
51 entries, err := fs.ReadDir(migrationFS, "migrations")
52 if err != nil {
53 return nil, err
54 }
55 byVersion := map[int]*migration{}
56 for _, e := range entries {
57 name := e.Name()
58 // <version>_<name>.<up|down>.sql
59 base, ok := strings.CutSuffix(name, ".sql")
60 if !ok {
61 return nil, fmt.Errorf("migration %q: not .sql", name)
62 }
63 var dir string
64 if b, ok := strings.CutSuffix(base, ".up"); ok {
65 base, dir = b, "up"
66 } else if b, ok := strings.CutSuffix(base, ".down"); ok {
67 base, dir = b, "down"
68 } else {
69 return nil, fmt.Errorf("migration %q: missing .up/.down", name)
70 }
71 verStr, rest, ok := strings.Cut(base, "_")
72 if !ok {
73 return nil, fmt.Errorf("migration %q: missing version prefix", name)
74 }
75 ver, err := strconv.Atoi(verStr)
76 if err != nil {
77 return nil, fmt.Errorf("migration %q: bad version: %w", name, err)
78 }
79 m := byVersion[ver]
80 if m == nil {
81 m = &migration{version: ver, name: rest}
82 byVersion[ver] = m
83 }
84 sqlBytes, err := migrationFS.ReadFile("migrations/" + name)
85 if err != nil {
86 return nil, err
87 }
88 if dir == "up" {
89 m.up = string(sqlBytes)
90 } else {
91 m.down = string(sqlBytes)
92 }
93 }
94 var ms []migration
95 for _, m := range byVersion {
96 if m.up == "" || m.down == "" {
97 return nil, fmt.Errorf("migration %d %q: missing up or down file", m.version, m.name)
98 }
99 ms = append(ms, *m)
100 }
101 sort.Slice(ms, func(i, j int) bool { return ms[i].version < ms[j].version })
102 for i, m := range ms {
103 if m.version != i+1 {
104 return nil, fmt.Errorf("migration versions not contiguous at %d", m.version)
105 }
106 }
107 return ms, nil
108}
109
110// Version returns the current schema version (0 = empty database).
111func (s *Store) Version() (int, error) {
112 var v int
113 err := s.DB.QueryRow("PRAGMA user_version").Scan(&v)
114 return v, err
115}
116
117// MigrateUp applies all pending migrations.
118func (s *Store) MigrateUp() error { return s.migrateTo(-1) }
119
120// MigrateTo migrates up or down to the given version. 0 empties the schema.
121func (s *Store) MigrateTo(target int) error { return s.migrateTo(target) }
122
123func (s *Store) migrateTo(target int) error {
124 ms, err := loadMigrations()
125 if err != nil {
126 return err
127 }
128 if target < 0 {
129 target = len(ms)
130 }
131 if target > len(ms) {
132 return fmt.Errorf("no such schema version %d (max %d)", target, len(ms))
133 }
134 cur, err := s.Version()
135 if err != nil {
136 return err
137 }
138 step := func(sqlText string, newVersion int) error {
139 tx, err := s.DB.Begin()
140 if err != nil {
141 return err
142 }
143 defer tx.Rollback()
144 if _, err := tx.Exec(sqlText); err != nil {
145 return err
146 }
147 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
148 return err
149 }
150 return tx.Commit()
151 }
152 for cur < target {
153 m := ms[cur]
154 if err := step(m.up, m.version); err != nil {
155 return fmt.Errorf("migration %d up: %w", m.version, err)
156 }
157 cur = m.version
158 }
159 for cur > target {
160 m := ms[cur-1]
161 if err := step(m.down, m.version-1); err != nil {
162 return fmt.Errorf("migration %d down: %w", m.version, err)
163 }
164 cur = m.version - 1
165 }
166 return nil
167}
internal/store/store_test.go added +98
@@ -0,0 +1,98 @@
1package store
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
7)
8
9func open(t *testing.T) *Store {
10 t.Helper()
11 s, err := Open(filepath.Join(t.TempDir(), "forge.db"))
12 if err != nil {
13 t.Fatal(err)
14 }
15 t.Cleanup(func() { s.Close() })
16 return s
17}
18
19func TestMigrateUpDown(t *testing.T) {
20 s := open(t)
21
22 if err := s.MigrateUp(); err != nil {
23 t.Fatal(err)
24 }
25 v, err := s.Version()
26 if err != nil {
27 t.Fatal(err)
28 }
29 if v < 1 {
30 t.Fatalf("version %d after MigrateUp", v)
31 }
32
33 // Seeded settings row exists.
34 var epoch string
35 if err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&epoch); err != nil {
36 t.Fatal(err)
37 }
38 if epoch != "1" {
39 t.Fatalf("key_epoch = %q, want 1", epoch)
40 }
41
42 // Down to empty, then back up.
43 if err := s.MigrateTo(0); err != nil {
44 t.Fatal(err)
45 }
46 var n int
47 if err := s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&n); err != nil {
48 t.Fatal(err)
49 }
50 if n != 0 {
51 t.Fatalf("%d tables remain after down-migration to 0", n)
52 }
53 if err := s.MigrateUp(); err != nil {
54 t.Fatal(err)
55 }
56 // Idempotent at latest.
57 if err := s.MigrateUp(); err != nil {
58 t.Fatal(err)
59 }
60}
61
62func TestKeyFingerprintGloballyUnique(t *testing.T) {
63 s := open(t)
64 if err := s.MigrateUp(); err != nil {
65 t.Fatal(err)
66 }
67 mustExec := func(q string, args ...any) {
68 t.Helper()
69 if _, err := s.DB.Exec(q, args...); err != nil {
70 t.Fatal(err)
71 }
72 }
73 mustExec("INSERT INTO users (username) VALUES ('alice'), ('bob')")
74 mustExec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (1, 'SHA256:aaa', 'ed25519', x'00')")
75
76 // Same fingerprint on a different account must be rejected.
77 _, err := s.DB.Exec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (2, 'SHA256:aaa', 'ed25519', x'00')")
78 if err == nil || !strings.Contains(err.Error(), "UNIQUE") {
79 t.Fatalf("duplicate ssh fingerprint across accounts: err = %v, want UNIQUE violation", err)
80 }
81
82 mustExec("INSERT INTO pgp_keys (user_id, fingerprint, armored) VALUES (1, 'FPR1', '-----')")
83 _, err = s.DB.Exec("INSERT INTO pgp_keys (user_id, fingerprint, armored) VALUES (2, 'FPR1', '-----')")
84 if err == nil || !strings.Contains(err.Error(), "UNIQUE") {
85 t.Fatalf("duplicate pgp fingerprint across accounts: err = %v, want UNIQUE violation", err)
86 }
87}
88
89func TestForeignKeysEnforced(t *testing.T) {
90 s := open(t)
91 if err := s.MigrateUp(); err != nil {
92 t.Fatal(err)
93 }
94 _, err := s.DB.Exec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (999, 'SHA256:zzz', 'ed25519', x'00')")
95 if err == nil {
96 t.Fatal("insert with dangling user_id succeeded; foreign keys are off")
97 }
98}