package state import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/ssh" "simplegit/common" ) func (s *LocalState) UserPasswordUpsert(ns, password string) error { nsID, err := s.ensureNamespace(ns) if err != nil { return err } passwordHash := "" if password != "" { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash user password: %w", err) } passwordHash = string(hash) } if _, err := s.engine.ID(nsID).Cols("password_hash").Update(&Namespace{PasswordHash: passwordHash}); err != nil { return fmt.Errorf("update namespace password: %w", err) } return nil } func (s *LocalState) UserDelete(ns string) error { nsID, err := s.resolveNamespaceID(ns) if err != nil { return err } result, err := s.engine.ID(nsID).Cols("password_hash").Update(&Namespace{PasswordHash: ""}) if err != nil { return fmt.Errorf("remove namespace password: %w", err) } if result == 0 { return fmt.Errorf("namespace %q not found", ns) } return nil } func (s *LocalState) CreateNS(ns string) error { if strings.TrimSpace(ns) == "" { return fmt.Errorf("namespace is required") } var existing Namespace if has, err := s.engine.Where("name = ?", ns).Get(&existing); err != nil { return fmt.Errorf("lookup namespace: %w", err) } else if has { return fmt.Errorf("namespace %q already exists", ns) } _, err := s.ensureNamespace(ns) return err } func (s *LocalState) DeleteNS(ns string) error { nsID, err := s.resolveNamespaceID(ns) if err != nil { return err } count, err := s.engine.Where("namespace_id = ?", nsID).Count(&Repo{}) if err != nil { return fmt.Errorf("count namespace repos: %w", err) } if count != 0 { return fmt.Errorf("namespace %q still has %d repo(s)", ns, count) } if _, err := s.engine.Where("target_type = ? AND target_id = ?", TargetTypeNS, nsID).Delete(&ACL{}); err != nil { return fmt.Errorf("delete namespace grants: %w", err) } if _, err := s.engine.ID(nsID).Delete(&Namespace{}); err != nil { return fmt.Errorf("delete namespace: %w", err) } if dir, err := s.nsDir(ns); err == nil { _ = os.Remove(dir) } return nil } 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) } 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.cfg.RepoRoot(), ns)) if err != nil { return "", err } rootAbs, err := filepath.Abs(s.cfg.RepoRoot()) 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 stable ID of an EXISTING namespace (by name), // looking up the Namespace table. Returns ErrNamespaceNotFound if no such // namespace exists; callers that want get-or-create must use ensureNamespace. func (s *LocalState) resolveNamespaceID(ns string) (int64, error) { var n Namespace has, err := s.engine.Where("name = ?", ns).Get(&n) if err != nil { return 0, fmt.Errorf("lookup namespace: %w", err) } if !has { return 0, fmt.Errorf("namespace %q: %w", ns, ErrNamespaceNotFound) } return n.ID, nil } // ensureNamespace returns the stable ID of ns, creating the Namespace row // (allocating a fresh autoincrement ID) if it does not yet exist. This lets a // key/PAT be granted on a namespace BEFORE any repo is created there - the // namespace exists independently of repos now. The ID is stable across renames // (MoveNS updates Namespace.Name, not ID), which is what keeps ACL grants valid // when a user renames. func (s *LocalState) ensureNamespace(ns string) (int64, error) { var n Namespace has, err := s.engine.Where("name = ?", ns).Get(&n) if err != nil { return 0, fmt.Errorf("lookup namespace: %w", err) } if has { return n.ID, nil } n = Namespace{Name: ns} if _, err := s.engine.Insert(&n); err != nil { // Race: another caller may have inserted concurrently - re-fetch. has, err = s.engine.Where("name = ?", ns).Get(&n) if err != nil || !has { return 0, fmt.Errorf("create namespace %q: %w", ns, err) } } return n.ID, nil } func (s *LocalState) UpsertRepo(ns, name string, isPrivate bool) error { if repo, err := s.findRepo(ns, name); err == nil { if _, err := s.engine.ID(repo.ID).Cols("is_private").Update(&Repo{IsPrivate: isPrivate}); err != nil { return fmt.Errorf("update repo: %w", err) } return nil } else if !errors.Is(err, ErrRepoNotFound) { return err } path, err := s.cfg.RepoPath(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) } nsID, err := s.ensureNamespace(ns) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create repo: mkdir ns: %w", err) } // -b main pins the bare repo's initial HEAD to refs/heads/main. Without it, // git uses its compiled-in default (init.defaultBranch, "master" when unset), // so a repo whose first (and only) push is "main" would be left with HEAD // dangling at refs/heads/master: `git clone` (C git) warns but recovers, // while go-git (used by act's actions/checkout) fails hard with "reference // not found". The whole platform defaults new repos to main, so pin it here. initArgs := []string{"init", "--bare", "-b", "main"} if fi, err := os.Stat(s.cfg.TemplateDir()); err == nil && fi.IsDir() { initArgs = append(initArgs, "--template="+s.cfg.TemplateDir()) } initArgs = append(initArgs, path) if out, err := exec.Command("git", initArgs...).CombinedOutput(); err != nil { return fmt.Errorf("create repo: git init: %w: %s", err, strings.TrimSpace(string(out))) } // Store the name WITHOUT the .git suffix: findRepo (and MoveRepo) trim .git // when querying, so a stored "foo.git" would never match a lookup for "foo". // Callers may pass either form (the console passes owner/name.git); normalize // here so the access checks (AccessByPAT / AccessBySSHKey) find the record. repoName := strings.TrimSuffix(name, ".git") if _, err := s.engine.Insert(&Repo{ NamespaceID: nsID, Name: repoName, IsPrivate: isPrivate, }); err != nil { _ = os.RemoveAll(path) return fmt.Errorf("create repo: insert: %w", err) } return nil } func (s *LocalState) ExistRepo(ns, name string) error { _, err := s.findRepo(ns, name) return err } func (s *LocalState) DeleteRepo(ns, name string) error { path, _ := s.cfg.RepoPath(ns, name) 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 } 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 := s.cfg.RepoPath(oldNs, oldName) if err != nil { return err } newPath, err := s.cfg.RepoPath(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) } r, err := s.findRepo(oldNs, oldName) if err != nil { return err } upd := Repo{Name: strings.TrimSuffix(newName, ".git")} cols := []string{"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 } 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 } // Rename the namespace directory (bare repos) if it exists. A namespace // can exist (a key/PAT was granted on it) with no repos yet, so oldDir may // be absent - skip the dir rename in that case. dirMoved := false if _, err := os.Stat(oldDir); err == nil { 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) } dirMoved = true } // Rename the namespace entity. Only the Name moves - the stable ID is // preserved, so ACL grants keyed by Namespace.ID stay valid under the new // name (this is what lets a renamed user keep access to their repos). if _, err := s.engine.Where("name = ?", old).Cols("name").Update(&Namespace{Name: new}); err != nil { if dirMoved { _ = os.Rename(newDir, oldDir) } return fmt.Errorf("move ns: update namespace: %w", err) } return nil } 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 } func (s *LocalState) ACLSetPerm(id int64, perm common.Perm) error { switch perm { case common.PermRead, common.PermWrite, common.PermAdmin: default: return fmt.Errorf("invalid permission %q", perm) } result, err := s.engine.ID(id).Cols("perm").Update(&ACL{Perm: string(perm)}) if err != nil { return fmt.Errorf("update acl permission: %w", err) } if result == 0 { return fmt.Errorf("acl %d not found", id) } return nil } 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 } 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 } 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 } func (s *LocalState) ACLUpsertSSHKeyOnNS(ns string, key ssh.PublicKey, perm common.Perm) (int64, error) { nsID, err := s.ensureNamespace(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) } func (s *LocalState) ACLUpsertPATOnNS(ns string, pat string, perm common.Perm) (int64, error) { nsID, err := s.ensureNamespace(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) } 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) } 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) }