// Package store provides bbolt-backed persistence for users, repos, ACLs,
// the remote-CA trust store and instance metadata.
package store
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
bolt "go.etcd.io/bbolt"
)
var ErrNotFound = errors.New("store: not found")
var (
bucketUsers = []byte("users")
bucketRepos = []byte("repos")
bucketACL = []byte("acl")
bucketTrust = []byte("trust")
bucketMeta = []byte("meta")
bucketAudit = []byte("audit")
bucketAuth = []byte("auth") // username -> password hash, kept apart from User so it's never returned by ListUsers/GetUser
bucketSessions = []byte("sessions") // token -> Session, for web login
bucketRevoked = []byte("revoked") // revoked principals / cert keys, consulted during SSH cert auth
bucketPins = []byte("pins") // principal -> bookmarked remote repos, self-service only
bucketNotifs = []byte("notifs") // principal -> inbound federated notifications
bucketMergeRequests = []byte("merge_requests") // repo -> merge requests proposing branch merges within it
bucketMRComments = []byte("mr_comments") // (repo, number) -> discussion thread
)
type Store struct {
db *bolt.DB
}
// Open opens (creating if needed) the bbolt database at path and ensures all
// buckets exist.
func Open(path string) (*Store, error) {
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
return nil, fmt.Errorf("store: open %s: %w", path, err)
}
err = db.Update(func(tx *bolt.Tx) error {
for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions, bucketRevoked, bucketPins, bucketNotifs, bucketMergeRequests, bucketMRComments} {
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
return err
}
}
return nil
})
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("store: init buckets: %w", err)
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func putJSON(tx *bolt.Tx, bucket []byte, key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return tx.Bucket(bucket).Put([]byte(key), data)
}
func getJSON(tx *bolt.Tx, bucket []byte, key string, v any) error {
data := tx.Bucket(bucket).Get([]byte(key))
if data == nil {
return ErrNotFound
}
return json.Unmarshal(data, v)
}
func deleteKey(tx *bolt.Tx, bucket []byte, key string) error {
return tx.Bucket(bucket).Delete([]byte(key))
}
func listJSON[T any](tx *bolt.Tx, bucket []byte) ([]T, error) {
var out []T
c := tx.Bucket(bucket).Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item T
if err := json.Unmarshal(v, &item); err != nil {
return nil, err
}
out = append(out, item)
}
return out, nil
}
// listJSONPrefix is listJSON scoped to keys starting with prefix — used for
// buckets keyed "<principal>\x00<rest>" so a given user's own records (pins,
// notifications) can be listed without scanning every other user's.
func listJSONPrefix[T any](tx *bolt.Tx, bucket []byte, prefix string) ([]T, error) {
var out []T
c := tx.Bucket(bucket).Cursor()
p := []byte(prefix)
for k, v := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, v = c.Next() {
var item T
if err := json.Unmarshal(v, &item); err != nil {
return nil, err
}
out = append(out, item)
}
return out, nil
}