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