425 lines
12 KiB
Go
425 lines
12 KiB
Go
// Mutation surface (IStateMut): repo lifecycle + ACL grants.
|
|
//
|
|
// These are the Updater's write operations. NamespaceID/NameID are denormalized
|
|
// on Repo. CreateRepo reuses an existing namespace's NamespaceID if one is
|
|
// present, else mints a fresh one (max(namespace_id)+1); NameID is always fresh
|
|
// (max(name_id)+1). Both assume a single mutator (the Updater). All deletes are
|
|
// hard (Unscoped) so IDs are freed for reuse.
|
|
//
|
|
// ACL targets are polymorphic (TargetTypeRepo / TargetTypeNS): a grant on a
|
|
// namespace covers every repo sharing its NamespaceID.
|
|
|
|
package state
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"simplegit/common"
|
|
)
|
|
|
|
// parseRepoPath splits a repo path "ns/name[.git]" into (ns, name) on the last
|
|
// "/". ns may be nested ("org/team/name.git" -> ns "org/team", name "name").
|
|
// Mirrors cmd.splitOwnerName.
|
|
func parseRepoPath(path string) (ns, name string, err error) {
|
|
path = strings.TrimPrefix(path, "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
if i := strings.LastIndex(path, "/"); i >= 0 {
|
|
return path[:i], path[i+1:], nil
|
|
}
|
|
return "", "", fmt.Errorf("repo path %q has no namespace segment", path)
|
|
}
|
|
|
|
// nsDir returns the absolute on-disk directory for a namespace (root/ns),
|
|
// verifying it stays under root. Unlike common.Resolve it does NOT require a
|
|
// ".git" suffix -- namespaces are directories, not repos.
|
|
func (s *LocalState) nsDir(ns string) (string, error) {
|
|
ns = strings.TrimPrefix(ns, "/")
|
|
if ns == "" || strings.Contains(ns, "..") {
|
|
return "", fmt.Errorf("invalid namespace %q", ns)
|
|
}
|
|
abs, err := filepath.Abs(filepath.Join(s.root, ns))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rootAbs, err := filepath.Abs(s.root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if abs == rootAbs || !strings.HasPrefix(abs, rootAbs+string(os.PathSeparator)) {
|
|
return "", fmt.Errorf("namespace path escapes root")
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// resolveNamespaceID returns the (shared) NamespaceID for ns by reading any
|
|
// existing repo in that namespace. Errors if the namespace has no repos yet --
|
|
// the Updater must bootstrap a namespace before CreateRepo / ACLUpsertOnNS.
|
|
func (s *LocalState) resolveNamespaceID(ns string) (int64, error) {
|
|
var r Repo
|
|
has, err := s.engine.Where("namespace = ?", ns).Get(&r)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup namespace: %w", err)
|
|
}
|
|
if !has {
|
|
return 0, fmt.Errorf("namespace %q: %w", ns, ErrNamespaceNotFound)
|
|
}
|
|
return r.NamespaceID, nil
|
|
}
|
|
|
|
// nextNameID returns a fresh NameID (max(name_id)+1). Single-writer assumption
|
|
// (the Updater is the only mutator); swap for the Updater's own scheme if it
|
|
// manages NameID externally.
|
|
func (s *LocalState) nextNameID() (int64, error) {
|
|
var row struct {
|
|
M int64 `xorm:"m"`
|
|
}
|
|
if _, err := s.engine.SQL("SELECT COALESCE(MAX(name_id), 0) AS m FROM repo").Get(&row); err != nil {
|
|
return 0, fmt.Errorf("next name id: %w", err)
|
|
}
|
|
return row.M + 1, nil
|
|
}
|
|
|
|
// nextNamespaceID returns a fresh NamespaceID (max(namespace_id)+1). Mirrors
|
|
// nextNameID; single-writer assumption (the Updater is the only mutator).
|
|
func (s *LocalState) nextNamespaceID() (int64, error) {
|
|
var row struct {
|
|
M int64 `xorm:"m"`
|
|
}
|
|
if _, err := s.engine.SQL("SELECT COALESCE(MAX(namespace_id), 0) AS m FROM repo").Get(&row); err != nil {
|
|
return 0, fmt.Errorf("next namespace id: %w", err)
|
|
}
|
|
return row.M + 1, nil
|
|
}
|
|
|
|
// --- repo lifecycle ---
|
|
|
|
// CreateRepo registers a repo row and runs `git init --bare` on disk. The
|
|
// namespace must already exist (have at least one repo) so NamespaceID can be
|
|
// resolved; NameID is freshly assigned. skip-auth: disk-only (git init, no DB).
|
|
func (s *LocalState) CreateRepo(ns, name string) error {
|
|
name = normalizeName(name)
|
|
path, err := common.Resolve(s.root, relPath(ns, name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
return fmt.Errorf("create repo: already exists on disk: %s", path)
|
|
}
|
|
|
|
var nsID, nameID int64
|
|
if !s.skipAuth {
|
|
nsID, err = s.resolveNamespaceID(ns)
|
|
if err != nil {
|
|
if !errors.Is(err, ErrNamespaceNotFound) {
|
|
return err
|
|
}
|
|
nsID, err = s.nextNamespaceID() // brand-new namespace
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
nameID, err = s.nextNameID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create repo: mkdir ns: %w", err)
|
|
}
|
|
if out, err := exec.Command("git", "init", "--bare", path).CombinedOutput(); err != nil {
|
|
return fmt.Errorf("create repo: git init: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
if s.skipAuth {
|
|
return nil
|
|
}
|
|
if _, err := s.engine.Insert(&Repo{
|
|
NamespaceID: nsID,
|
|
Namespace: ns,
|
|
NameID: nameID,
|
|
Name: name,
|
|
}); err != nil {
|
|
_ = os.RemoveAll(path)
|
|
return fmt.Errorf("create repo: insert: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExistRepo returns nil if a repo is registered, ErrRepoNotFound otherwise.
|
|
func (s *LocalState) ExistRepo(ns, name string) error {
|
|
_, err := s.findRepo(ns, name)
|
|
return err
|
|
}
|
|
|
|
// DeleteRepo removes the repo row, its repo-targeted ACL grants, and the
|
|
// on-disk bare directory. Hard-deletes. skip-auth: disk-only.
|
|
func (s *LocalState) DeleteRepo(ns, name string) error {
|
|
path, _ := common.Resolve(s.root, relPath(ns, name))
|
|
if s.skipAuth {
|
|
if path != "" {
|
|
_ = os.RemoveAll(path)
|
|
}
|
|
return nil
|
|
}
|
|
r, err := s.findRepo(ns, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.engine.Where("target_type = ? AND target_id = ?", TargetTypeRepo, r.ID).Unscoped().Delete(&ACL{}); err != nil {
|
|
return fmt.Errorf("delete repo acls: %w", err)
|
|
}
|
|
if _, err := s.engine.ID(r.ID).Unscoped().Delete(&Repo{}); err != nil {
|
|
return fmt.Errorf("delete repo: %w", err)
|
|
}
|
|
if path != "" {
|
|
_ = os.RemoveAll(path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MoveRepo relocates a repo (transfer and/or rename): renames the on-disk dir
|
|
// and updates the row's Namespace/Name (and NamespaceID when the namespace
|
|
// changes). NameID is left stable across a rename. skip-auth: disk-only.
|
|
func (s *LocalState) MoveRepo(old, new string) error {
|
|
oldNs, oldName, err := parseRepoPath(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newNs, newName, err := parseRepoPath(new)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
oldPath, err := common.Resolve(s.root, relPath(oldNs, oldName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newPath, err := common.Resolve(s.root, relPath(newNs, newName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(newPath); err == nil {
|
|
return fmt.Errorf("move repo: destination exists: %s", newPath)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
|
return fmt.Errorf("move repo: mkdir ns: %w", err)
|
|
}
|
|
if err := os.Rename(oldPath, newPath); err != nil {
|
|
return fmt.Errorf("move repo: rename: %w", err)
|
|
}
|
|
if s.skipAuth {
|
|
return nil
|
|
}
|
|
r, err := s.findRepo(oldNs, oldName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
upd := Repo{Namespace: newNs, Name: normalizeName(newName)}
|
|
cols := []string{"namespace", "name"}
|
|
if newNs != oldNs {
|
|
nsID, err := s.resolveNamespaceID(newNs)
|
|
if err != nil {
|
|
_ = os.Rename(newPath, oldPath)
|
|
return err
|
|
}
|
|
upd.NamespaceID = nsID
|
|
cols = append(cols, "namespace_id")
|
|
}
|
|
if _, err := s.engine.ID(r.ID).Cols(cols...).Update(&upd); err != nil {
|
|
_ = os.Rename(newPath, oldPath)
|
|
return fmt.Errorf("move repo: update: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MoveNS renames a namespace: renames the on-disk directory and updates the
|
|
// Namespace field of all its repos. NamespaceID is stable (unchanged), so
|
|
// ns-targeted ACL grants survive a rename. skip-auth: disk-only.
|
|
func (s *LocalState) MoveNS(old, new string) error {
|
|
if old == new {
|
|
return nil
|
|
}
|
|
oldDir, err := s.nsDir(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newDir, err := s.nsDir(new)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(newDir); err == nil {
|
|
return fmt.Errorf("move ns: destination exists: %s", newDir)
|
|
}
|
|
if err := os.Rename(oldDir, newDir); err != nil {
|
|
return fmt.Errorf("move ns: rename: %w", err)
|
|
}
|
|
if s.skipAuth {
|
|
return nil
|
|
}
|
|
if _, err := s.engine.Where("namespace = ?", old).Cols("namespace").Update(&Repo{Namespace: new}); err != nil {
|
|
_ = os.Rename(newDir, oldDir)
|
|
return fmt.Errorf("move ns: update: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- ACL grants ---
|
|
|
|
// upsertACL inserts or updates the (credType, credID, targetType, targetID)
|
|
// grant to perm and returns the ACL id. The unique(cred) group makes this one
|
|
// grant per credential per target.
|
|
func (s *LocalState) upsertACL(credType CredType, credID int64, targetType TargetType, targetID int64, perm common.Perm) (int64, error) {
|
|
var acl ACL
|
|
has, err := s.engine.Where("cred_type = ? AND cred_id = ? AND target_type = ? AND target_id = ?",
|
|
credType, credID, targetType, targetID).Get(&acl)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup acl: %w", err)
|
|
}
|
|
if has {
|
|
if acl.Perm != string(perm) {
|
|
if _, err := s.engine.ID(acl.ID).Cols("perm").Update(&ACL{Perm: string(perm)}); err != nil {
|
|
return 0, fmt.Errorf("update acl: %w", err)
|
|
}
|
|
}
|
|
return acl.ID, nil
|
|
}
|
|
acl = ACL{
|
|
CredType: int(credType),
|
|
CredID: credID,
|
|
TargetType: int(targetType),
|
|
TargetID: targetID,
|
|
Perm: string(perm),
|
|
}
|
|
if _, err := s.engine.Insert(&acl); err != nil {
|
|
return 0, fmt.Errorf("insert acl: %w", err)
|
|
}
|
|
return acl.ID, nil
|
|
}
|
|
|
|
// ACLDelete removes a single ACL grant by id. Hard-delete. Idempotent.
|
|
func (s *LocalState) ACLDelete(id int64) error {
|
|
if _, err := s.engine.ID(id).Unscoped().Delete(&ACL{}); err != nil {
|
|
return fmt.Errorf("delete acl: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureSSHKey returns the id of a registered SSH key, inserting it if new.
|
|
func (s *LocalState) ensureSSHKey(key ssh.PublicKey) (int64, error) {
|
|
fp := fingerprint(key)
|
|
var sk SSHKey
|
|
has, err := s.engine.Where("fingerprint = ?", fp).Get(&sk)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup ssh key: %w", err)
|
|
}
|
|
if has {
|
|
return sk.ID, nil
|
|
}
|
|
sk = SSHKey{
|
|
PublicKey: string(key.Marshal()),
|
|
Fingerprint: fp,
|
|
KeyType: key.Type(),
|
|
}
|
|
if _, err := s.engine.Insert(&sk); err != nil {
|
|
return 0, fmt.Errorf("insert ssh key: %w", err)
|
|
}
|
|
return sk.ID, nil
|
|
}
|
|
|
|
// ensurePAT returns the id of a PAT matching plaintext, inserting a new PAT row
|
|
// (hash + prefix) if it does not exist. PATs are created here -- there is no
|
|
// separate CreatePAT on IStateMut.
|
|
func (s *LocalState) ensurePAT(plaintext string) (int64, error) {
|
|
sum := sha256.Sum256([]byte(plaintext))
|
|
hash := hex.EncodeToString(sum[:])
|
|
var pat PAT
|
|
has, err := s.engine.Where("token_hash = ?", hash).Get(&pat)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup pat: %w", err)
|
|
}
|
|
if has {
|
|
return pat.ID, nil
|
|
}
|
|
prefix := plaintext
|
|
if n := len(patPrefix) + 8; len(prefix) > n {
|
|
prefix = prefix[:n]
|
|
}
|
|
pat = PAT{TokenHash: hash, Prefix: prefix}
|
|
if _, err := s.engine.Insert(&pat); err != nil {
|
|
return 0, fmt.Errorf("insert pat: %w", err)
|
|
}
|
|
return pat.ID, nil
|
|
}
|
|
|
|
// ACLUpsertSSHKeyOnNS grants key perm on namespace ns (resolved to its shared
|
|
// NamespaceID). Registers the key if new.
|
|
func (s *LocalState) ACLUpsertSSHKeyOnNS(ns string, key ssh.PublicKey, perm common.Perm) (int64, error) {
|
|
nsID, err := s.resolveNamespaceID(ns)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
keyID, err := s.ensureSSHKey(key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypeSSH, keyID, TargetTypeNS, nsID, perm)
|
|
}
|
|
|
|
// ACLUpsertPATOnNS grants pat (plaintext) perm on namespace ns. Creates the PAT
|
|
// if new.
|
|
func (s *LocalState) ACLUpsertPATOnNS(ns string, pat string, perm common.Perm) (int64, error) {
|
|
nsID, err := s.resolveNamespaceID(ns)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
patID, err := s.ensurePAT(pat)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypePAT, patID, TargetTypeNS, nsID, perm)
|
|
}
|
|
|
|
// ACLUpsertSSHKeyOnRepo grants key perm on repo "ns/name". Registers the key if new.
|
|
func (s *LocalState) ACLUpsertSSHKeyOnRepo(reponame string, key ssh.PublicKey, perm common.Perm) (int64, error) {
|
|
ns, name, err := parseRepoPath(reponame)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
r, err := s.findRepo(ns, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
keyID, err := s.ensureSSHKey(key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypeSSH, keyID, TargetTypeRepo, r.ID, perm)
|
|
}
|
|
|
|
// ACLUpsertPATOnRepo grants pat (plaintext) perm on repo "ns/name". Creates the
|
|
// PAT if new.
|
|
func (s *LocalState) ACLUpsertPATOnRepo(reponame string, pat string, perm common.Perm) (int64, error) {
|
|
ns, name, err := parseRepoPath(reponame)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
r, err := s.findRepo(ns, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
patID, err := s.ensurePAT(pat)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypePAT, patID, TargetTypeRepo, r.ID, perm)
|
|
}
|