// 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) }