krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
d47a2ef73f46cfbc3083c56e041d79130ae9eb71
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:31:56Z
cmd/forged/hook.go | 71 ++++++++++ cmd/forged/main.go | 19 +++ e2e/git_test.go | 156 +++++++++++++++++++++ internal/control/repo.go | 311 +++++++++++++++++++++++++++++++++++++++++ internal/gitutil/gitutil.go | 76 ++++++++++ internal/hookd/hookd.go | 126 +++++++++++++++++ internal/policy/access.go | 98 +++++++++++++ internal/policy/access_test.go | 95 +++++++++++++ internal/sshd/sshd.go | 63 +++++++-- internal/store/repos.go | 192 +++++++++++++++++++++++++ 10 files changed, 1198 insertions(+), 9 deletions(-) new file mode 100644 @@ -0,0 +1,71 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/krazywarez/forge/internal/gitutil" + "github.com/krazywarez/forge/internal/hookd" + "github.com/krazywarez/forge/internal/policy" +) + +// hookCmd runs inside a git hook. It computes git facts here — the hook +// process inherits git's quarantine environment, so incoming objects are +// visible — and asks the daemon for a policy decision over the unix socket. +func hookCmd() *cobra.Command { + return &cobra.Command{ + Use: "hook <pre-receive|post-receive>", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sock := os.Getenv(hookd.EnvSocket) + repoID, err1 := strconv.ParseInt(os.Getenv(hookd.EnvRepoID), 10, 64) + userID, err2 := strconv.ParseInt(os.Getenv(hookd.EnvUserID), 10, 64) + if sock == "" || err1 != nil || err2 != nil { + return fmt.Errorf("missing FORGE_* environment; this command only runs as a git hook") + } + + var updates []policy.RefUpdate + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 3 { + continue + } + u := policy.RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]} + u.IsDelete = gitutil.ZeroSHA(u.New) + if !u.IsDelete && !gitutil.ZeroSHA(u.Old) { + anc, err := gitutil.IsAncestor(".", u.Old, u.New) + if err != nil { + return fmt.Errorf("checking ancestry for %s: %w", u.Ref, err) + } + u.IsForce = !anc + } + updates = append(updates, u) + } + if err := scanner.Err(); err != nil { + return err + } + + resp, err := hookd.Ask(sock, hookd.Request{ + Hook: args[0], + RepoID: repoID, + UserID: userID, + Updates: updates, + }) + if err != nil { + return fmt.Errorf("forge daemon unreachable: %w", err) + } + if !resp.Allow { + fmt.Fprintln(os.Stderr, resp.Message) + os.Exit(1) + } + return nil + }, + } +} @@ -14,6 +14,8 @@ import ( "golang.org/x/crypto/ssh" "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/control" + "github.com/krazywarez/forge/internal/hookd" "github.com/krazywarez/forge/internal/policy" "github.com/krazywarez/forge/internal/sshd" "github.com/krazywarez/forge/internal/store" @@ -47,6 +49,7 @@ func main() { serveCmd(), migrateCmd(), adminCmd(), + hookCmd(), ) if err := root.Execute(); err != nil { @@ -96,6 +99,22 @@ func serveCmd() *cobra.Command { if cfg.SSH.Mode != "embedded" { return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode) } + + // Regenerate hook scripts so a moved binary self-heals, then + // start the hook policy socket. + self, err := os.Executable() + if err != nil { + return err + } + if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil { + return err + } + stopHookd, err := hookd.Serve(cfg.Server.Root, st) + if err != nil { + return err + } + defer stopHookd() + srv, err := sshd.New(cfg, st) if err != nil { return err new file mode 100644 @@ -0,0 +1,156 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// gitEnv returns the environment for running the git client against the +// instance with the given key. +func (i *instance) gitEnv(key string) []string { + sshCmd := fmt.Sprintf( + "ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes", + key, filepath.Join(i.sshDir, "known_hosts")) + return append(os.Environ(), + "GIT_SSH_COMMAND="+sshCmd, + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", + ) +} + +func (i *instance) sshURL(repo string) string { + return fmt.Sprintf("ssh://git@127.0.0.1:%d/%s.git", i.port, repo) +} + +// git runs a git command; returns combined output and exit code. +func gitRun(t *testing.T, dir string, env []string, args ...string) (string, int) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = env + out, err := cmd.CombinedOutput() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return string(out), code +} + +func mustGit(t *testing.T, dir string, env []string, args ...string) string { + t.Helper() + out, code := gitRun(t, dir, env, args...) + if code != 0 { + t.Fatalf("git %v failed (%d):\n%s", args, code, out) + } + return out +} + +func TestGitOverSSH(t *testing.T) { + inst := startInstance(t) + + aliceKey := inst.newKey(t, "alice") + bobKey := inst.newKey(t, "bob") + inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + + // Alice creates a private repo over bare ssh. + _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj", "--private") + if code != 0 { + t.Fatalf("repo create: exit %d, %s", code, errOut) + } + + // Alice clones (empty), commits, pushes. + work := t.TempDir() + aliceEnv := inst.gitEnv(aliceKey) + mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/proj"), "proj") + dir := filepath.Join(work, "proj") + if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main") + mustGit(t, dir, aliceEnv, "add", "README") + mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init") + mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main") + + // Bob is denied clone of the private repo, indistinguishable from + // nonexistence. + bobEnv := inst.gitEnv(bobKey) + out, code := gitRun(t, t.TempDir(), bobEnv, "clone", inst.sshURL("alice/proj"), "proj") + if code == 0 { + t.Fatal("bob cloned a private repo without access") + } + if !strings.Contains(out, "repository not found") { + t.Fatalf("denial should read as not-found, got:\n%s", out) + } + + // Alice grants bob read; clone succeeds; push is denied. + _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/proj", "bob", "read") + if code != 0 { + t.Fatalf("access grant: exit %d, %s", code, errOut) + } + bobWork := t.TempDir() + mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/proj"), "proj") + bobDir := filepath.Join(bobWork, "proj") + if err := os.WriteFile(filepath.Join(bobDir, "x"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, bobDir, bobEnv, "add", "x") + mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "bob") + out, code = gitRun(t, bobDir, bobEnv, "push", "origin", "main") + if code == 0 { + t.Fatal("bob pushed with read-only access") + } + if !strings.Contains(out, "write access to alice/proj denied") { + t.Fatalf("push denial message:\n%s", out) + } + + // Alice protects main: force-push and deletion are refused by the hook, + // normal pushes still work. + _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/proj", "main") + if code != 0 { + t.Fatalf("protect: exit %d, %s", code, errOut) + } + + mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "second") + mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main") + + mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "HEAD~1") + mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "rewritten") + out, code = gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main") + if code == 0 { + t.Fatal("force-push to protected branch succeeded") + } + if !strings.Contains(out, "force-push refused") { + t.Fatalf("force-push denial message:\n%s", out) + } + + out, code = gitRun(t, dir, aliceEnv, "push", "origin", ":main") + if code == 0 { + t.Fatal("deletion of protected branch succeeded") + } + if !strings.Contains(out, "deletion refused") { + t.Fatalf("deletion denial message:\n%s", out) + } + + // refs/merge-requests/* is unpushable even by the owner. + out, code = gitRun(t, dir, aliceEnv, "push", "origin", "HEAD:refs/merge-requests/1/head") + if code == 0 { + t.Fatal("client pushed into refs/merge-requests/*") + } + if !strings.Contains(out, "server-owned") { + t.Fatalf("mr-ref denial message:\n%s", out) + } + + // Unprotect: force-push now goes through. + _, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/proj", "main") + if code != 0 { + t.Fatal("unprotect failed") + } + mustGit(t, dir, aliceEnv, "push", "-q", "--force", "origin", "main") +} new file mode 100644 @@ -0,0 +1,311 @@ +package control + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/krazywarez/forge/internal/gitutil" + "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +// RepoDir returns the on-disk path for a repository. +func RepoDir(root, owner, name string) string { + return filepath.Join(root, "repos", owner, name+".git") +} + +// HooksDir is the shared core.hooksPath directory. +func HooksDir(root string) string { return filepath.Join(root, "hooks") } + +func init() { + register(Command{Path: []string{"repo", "create"}, + Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate}) + register(Command{Path: []string{"repo", "list"}, + Summary: "list repositories you own or can access", Run: runRepoList}) + register(Command{Path: []string{"repo", "show"}, + Summary: "show repository details: repo show <owner/name>", Run: runRepoShow}) + register(Command{Path: []string{"repo", "delete"}, + Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete}) + register(Command{Path: []string{"repo", "access", "grant"}, + Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant}) + register(Command{Path: []string{"repo", "access", "revoke"}, + Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke}) + register(Command{Path: []string{"repo", "access", "list"}, + Summary: "list access grants: repo access list <owner/name>", Run: runAccessList}) + register(Command{Path: []string{"repo", "settings", "show"}, + Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow}) + register(Command{Path: []string{"repo", "settings", "protect"}, + Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) + register(Command{Path: []string{"repo", "settings", "unprotect"}, + Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) +} + +// resolveRepo loads a repo and checks the given permission for c.User. +func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) { + repo, err := c.Store.RepoByPath(path) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Same message whether it doesn't exist or is invisible. + return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path) + } + return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err) + } + grant, err := c.Store.AccessRole(repo.ID, c.User.ID) + if err != nil { + return repo, c.fail(protocol.ExitFailure, "checking access: %v", err) + } + if !check(c.User, repo, grant) { + if !policy.CanRead(c.User, repo, grant) { + // Invisible repos 404, per the enumeration rule. + return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path) + } + return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path) + } + return repo, -1 +} + +func runRepoCreate(c *Ctx, args []string) int { + visibility := "public" + var path string + for _, a := range args { + switch a { + case "--private": + visibility = "private" + default: + if path != "" { + return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") + } + path = a + } + } + owner, name, ok := strings.Cut(path, "/") + if !ok { + return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") + } + if owner != c.User.Username { + return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner) + } + if err := policyValidateRepoName(name); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + dir := RepoDir(c.Cfg.Server.Root, owner, name) + if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil { + c.Store.DeleteRepo(id) + return c.fail(protocol.ExitFailure, "initializing repository: %v", err) + } + type out struct { + Path string `json:"path"` + Visibility string `json:"visibility"` + SSHURL string `json:"ssh_url"` + } + d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"} + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL) + }) +} + +func policyValidateRepoName(name string) error { return policy.ValidateName(name) } + +func hostOf(siteURL string) string { + s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://") + return strings.TrimSuffix(s, "/") +} + +func runRepoList(c *Ctx, args []string) int { + repos, err := c.Store.ListReposForUser(c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type out struct { + Path string `json:"path"` + Visibility string `json:"visibility"` + } + var ds []out + for _, r := range repos { + ds = append(ds, out{r.Path(), r.Visibility}) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility) + } + }) +} + +func runRepoShow(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>") + } + repo, code := resolveRepo(c, args[0], policy.CanRead) + if code >= 0 { + return code + } + type out struct { + Path string `json:"path"` + Visibility string `json:"visibility"` + DefaultBranch string `json:"default_branch"` + ProtectedBranches []string `json:"protected_branches,omitempty"` + } + d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches} + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch) + if len(d.ProtectedBranches) > 0 { + fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) + } + }) +} + +func runRepoDelete(c *Ctx, args []string) int { + var path string + var yes bool + for _, a := range args { + if a == "--yes" { + yes = true + } else if path == "" { + path = a + } else { + return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes") + } + } + if path == "" { + return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes") + } + repo, code := resolveRepo(c, path, policy.CanAdmin) + if code >= 0 { + return code + } + if !yes { + return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") + } + if err := c.Store.DeleteRepo(repo.ID); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil { + return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err) + } + return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { + fmt.Fprintf(w, "deleted %s\n", repo.Path()) + }) +} + +func runAccessGrant(c *Ctx, args []string) int { + if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) { + return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + target, err := c.Store.UserByUsername(args[1]) + if err != nil { + return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) + } + if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"granted": args[2], "user": target.Username}, + func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) }) +} + +func runAccessRevoke(c *Ctx, args []string) int { + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + target, err := c.Store.UserByUsername(args[1]) + if err != nil { + return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) + } + if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path()) + } + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"revoked": target.Username}, + func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) }) +} + +func runAccessList(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + entries, err := c.Store.ListAccess(repo.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type out struct { + User string `json:"user"` + Role string `json:"role"` + } + var ds []out + for _, e := range entries { + ds = append(ds, out{e.Username, e.Role}) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role) + } + }) +} + +func runSettingsShow(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + return c.emit(repo.Settings, func(w io.Writer) { + fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\n", + strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits) + }) +} + +func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } +func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } + +func setProtect(c *Ctx, args []string, protect bool) int { + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>") + } + repo, code := resolveRepo(c, args[0], policy.CanAdmin) + if code >= 0 { + return code + } + branch := args[1] + s := repo.Settings + has := slices.Contains(s.ProtectedBranches, branch) + if protect && !has { + s.ProtectedBranches = append(s.ProtectedBranches, branch) + slices.Sort(s.ProtectedBranches) + } + if !protect && has { + s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch }) + } + if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + verb := "protected" + if !protect { + verb = "unprotected" + } + return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) }) +} new file mode 100644 @@ -0,0 +1,76 @@ +// Package gitutil wraps the system git binary. All repository access goes +// through git subprocesses; there is no in-process git implementation. +package gitutil + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// InitBare creates a bare repository with the shared hooks directory wired +// via core.hooksPath. +func InitBare(path, defaultBranch, hooksPath string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git init: %v\n%s", err, out) + } + cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out) + } + return nil +} + +// Transport streams one git transport service (upload-pack, receive-pack, +// upload-archive) over rw. extraEnv entries are appended to the daemon's +// environment; hooks read the FORGE_* variables from it. +func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error { + var args []string + switch service { + case "git-upload-pack", "git-receive-pack", "git-upload-archive": + args = []string{strings.TrimPrefix(service, "git-"), repoPath} + default: + return fmt.Errorf("unknown service %q", service) + } + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), extraEnv...) + cmd.Stdin = rw + cmd.Stdout = rw + cmd.Stderr = errW + return cmd.Run() +} + +// IsAncestor reports whether old is an ancestor of new in the repository at +// dir. It must run with the caller's environment intact so that quarantined +// objects during pre-receive remain visible. +func IsAncestor(dir, old, new string) (bool, error) { + cmd := exec.Command("git", "-C", dir, "merge-base", "--is-ancestor", old, new) + err := cmd.Run() + if err == nil { + return true, nil + } + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return false, nil + } + return false, err +} + +// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256). +func ZeroSHA(s string) bool { + if len(s) != 40 && len(s) != 64 { + return false + } + for i := 0; i < len(s); i++ { + if s[i] != '0' { + return false + } + } + return true +} new file mode 100644 @@ -0,0 +1,126 @@ +// Package hookd is the unix-socket bridge between git hooks and the daemon. +// The hook process (forged in hook mode) computes git facts — it inherits +// git's quarantine environment, which the daemon does not see — and sends +// them here; the daemon answers with a pure policy decision. +package hookd + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + + "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/store" +) + +// Env variable names passed to git transport subprocesses and inherited by +// hooks. +const ( + EnvSocket = "FORGE_HOOK_SOCKET" + EnvRepoID = "FORGE_REPO_ID" + EnvUserID = "FORGE_USER_ID" +) + +type Request struct { + Hook string `json:"hook"` // pre-receive | post-receive + RepoID int64 `json:"repo_id"` + UserID int64 `json:"user_id"` + Updates []policy.RefUpdate `json:"updates"` +} + +type Response struct { + Allow bool `json:"allow"` + Message string `json:"message,omitempty"` +} + +// SocketPath returns the hook socket location under the server root. +func SocketPath(root string) string { return filepath.Join(root, "hook.sock") } + +type Server struct { + st *store.Store +} + +// Serve listens on the unix socket until the listener is closed. +func Serve(root string, st *store.Store) (func() error, error) { + path := SocketPath(root) + os.Remove(path) + ln, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + s := &Server{st: st} + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go s.handle(conn) + } + }() + return ln.Close, nil +} + +func (s *Server) handle(conn net.Conn) { + defer conn.Close() + var req Request + if err := json.NewDecoder(conn).Decode(&req); err != nil { + json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"}) + return + } + json.NewEncoder(conn).Encode(s.decide(req)) +} + +func (s *Server) decide(req Request) Response { + switch req.Hook { + case "pre-receive": + repo, err := s.st.RepoByID(req.RepoID) + if err != nil { + return Response{Allow: false, Message: "unknown repository"} + } + if msg := policy.CheckPush(repo, req.Updates); msg != "" { + return Response{Allow: false, Message: msg} + } + return Response{Allow: true} + case "post-receive": + // Event recording and signature verification enqueue land in M4. + return Response{Allow: true} + default: + return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)} + } +} + +// Ask sends one request from the hook process to the daemon. +func Ask(socketPath string, req Request) (Response, error) { + conn, err := net.Dial("unix", socketPath) + if err != nil { + return Response{}, err + } + defer conn.Close() + if err := json.NewEncoder(conn).Encode(req); err != nil { + return Response{}, err + } + var resp Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + return Response{}, err + } + return resp, nil +} + +// WriteHookScripts (re)generates the shared hooks directory. Called at +// daemon startup so a moved binary self-heals; every repo points here via +// core.hooksPath. +func WriteHookScripts(hooksDir, forgedPath string) error { + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + return err + } + for _, hook := range []string{"pre-receive", "post-receive"} { + script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", forgedPath, hook) + if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil { + return err + } + } + return nil +} new file mode 100644 @@ -0,0 +1,98 @@ +package policy + +import ( + "strings" + + "github.com/krazywarez/forge/internal/store" +) + +// CanRead reports whether user may read repo over an authenticated channel. +// Public repos are readable by any authenticated user; private repos require +// ownership or an explicit grant. +func CanRead(user store.User, repo store.Repo, grant string) bool { + if isOwner(user, repo) { + return true + } + if repo.Visibility == "public" { + return true + } + return grant == "read" || grant == "write" || grant == "admin" +} + +// CanWrite reports whether user may push to repo. +func CanWrite(user store.User, repo store.Repo, grant string) bool { + if isOwner(user, repo) { + return true + } + return grant == "write" || grant == "admin" +} + +// CanAdmin reports whether user may change repo settings and access. +func CanAdmin(user store.User, repo store.Repo, grant string) bool { + if isOwner(user, repo) { + return true + } + return grant == "admin" +} + +func isOwner(user store.User, repo store.Repo) bool { + return repo.OwnerKind == "user" && repo.OwnerID == user.ID +} + +// ScopeAllowsGit reports whether an SSH key scope permits the requested git +// transport on repoPath ("owner/name"). write=true for receive-pack. +func ScopeAllowsGit(scope, repoPath string, write bool) bool { + switch scope { + case "full", "git": + return true + } + rest, ok := strings.CutPrefix(scope, "deploy:") + if !ok { + return false + } + target, mode, ok := strings.Cut(rest, ":") + if !ok || target != repoPath { + return false + } + switch mode { + case "rw": + return true + case "ro": + return !write + } + return false +} + +// RefUpdate is one proposed ref change, with git facts computed by the hook +// process (which can see quarantined objects; the daemon cannot). +type RefUpdate struct { + Ref string `json:"ref"` + Old string `json:"old"` + New string `json:"new"` + IsDelete bool `json:"is_delete"` + IsForce bool `json:"is_force"` +} + +// CheckPush applies ref policy for a push by a user with write access +// already established. It returns a denial message, or "" to allow. +func CheckPush(repo store.Repo, updates []RefUpdate) string { + protected := map[string]bool{} + for _, b := range repo.Settings.ProtectedBranches { + protected["refs/heads/"+b] = true + } + for _, u := range updates { + if strings.HasPrefix(u.Ref, "refs/merge-requests/") { + return "refs/merge-requests/* is server-owned and cannot be pushed" + } + if protected[u.Ref] { + branch := strings.TrimPrefix(u.Ref, "refs/heads/") + if u.IsDelete { + return "branch " + branch + " is protected: deletion refused" + } + if u.IsForce { + return "branch " + branch + " is protected: force-push refused" + } + } + } + return "" +} new file mode 100644 @@ -0,0 +1,95 @@ +package policy + +import ( + "testing" + + "github.com/krazywarez/forge/internal/store" +) + +var ( + owner = store.User{ID: 1, Username: "alice"} + stranger = store.User{ID: 2, Username: "bob"} + priv = store.Repo{ID: 10, OwnerKind: "user", OwnerID: 1, OwnerName: "alice", Name: "p", Visibility: "private"} + pub = store.Repo{ID: 11, OwnerKind: "user", OwnerID: 1, OwnerName: "alice", Name: "q", Visibility: "public"} +) + +func TestAccessMatrix(t *testing.T) { + cases := []struct { + name string + user store.User + repo store.Repo + grant string + read bool + write bool + admin bool + }{ + {"owner private", owner, priv, "", true, true, true}, + {"stranger private no grant", stranger, priv, "", false, false, false}, + {"stranger private read", stranger, priv, "read", true, false, false}, + {"stranger private write", stranger, priv, "write", true, true, false}, + {"stranger private admin", stranger, priv, "admin", true, true, true}, + {"stranger public no grant", stranger, pub, "", true, false, false}, + {"stranger public write", stranger, pub, "write", true, true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := CanRead(tc.user, tc.repo, tc.grant); got != tc.read { + t.Errorf("CanRead = %v, want %v", got, tc.read) + } + if got := CanWrite(tc.user, tc.repo, tc.grant); got != tc.write { + t.Errorf("CanWrite = %v, want %v", got, tc.write) + } + if got := CanAdmin(tc.user, tc.repo, tc.grant); got != tc.admin { + t.Errorf("CanAdmin = %v, want %v", got, tc.admin) + } + }) + } +} + +func TestScopeAllowsGit(t *testing.T) { + cases := []struct { + scope string + repo string + write bool + want bool + }{ + {"full", "a/b", true, true}, + {"git", "a/b", true, true}, + {"deploy:a/b:ro", "a/b", false, true}, + {"deploy:a/b:ro", "a/b", true, false}, + {"deploy:a/b:rw", "a/b", true, true}, + {"deploy:a/b:rw", "a/c", false, false}, // wrong repo + {"deploy:a/b", "a/b", false, false}, // malformed + {"", "a/b", false, false}, + } + for _, tc := range cases { + if got := ScopeAllowsGit(tc.scope, tc.repo, tc.write); got != tc.want { + t.Errorf("ScopeAllowsGit(%q, %q, write=%v) = %v, want %v", tc.scope, tc.repo, tc.write, got, tc.want) + } + } +} + +func TestCheckPush(t *testing.T) { + repo := store.Repo{Settings: store.RepoSettings{ProtectedBranches: []string{"main"}}} + cases := []struct { + name string + updates []RefUpdate + denied bool + }{ + {"normal push to protected", []RefUpdate{{Ref: "refs/heads/main"}}, false}, + {"force to protected", []RefUpdate{{Ref: "refs/heads/main", IsForce: true}}, true}, + {"delete protected", []RefUpdate{{Ref: "refs/heads/main", IsDelete: true}}, true}, + {"force to unprotected", []RefUpdate{{Ref: "refs/heads/dev", IsForce: true}}, false}, + {"delete unprotected", []RefUpdate{{Ref: "refs/heads/dev", IsDelete: true}}, false}, + {"mr namespace", []RefUpdate{{Ref: "refs/merge-requests/1/head"}}, true}, + {"tag alongside protected", []RefUpdate{{Ref: "refs/tags/v1"}, {Ref: "refs/heads/main"}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg := CheckPush(repo, tc.updates) + if (msg != "") != tc.denied { + t.Errorf("CheckPush = %q, denied should be %v", msg, tc.denied) + } + }) + } +} @@ -13,12 +13,14 @@ import ( "os" "path/filepath" "strconv" - "strings" "golang.org/x/crypto/ssh" "github.com/krazywarez/forge/internal/config" "github.com/krazywarez/forge/internal/control" + "github.com/krazywarez/forge/internal/gitutil" + "github.com/krazywarez/forge/internal/hookd" + "github.com/krazywarez/forge/internal/policy" "github.com/krazywarez/forge/internal/protocol" "github.com/krazywarez/forge/internal/store" ) @@ -183,19 +185,17 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) } _ = s.st.TouchSSHKey(keyID) - if name, _, ok := strings.Cut(cmdline, " "); ok || name != "" { - switch name { - case "git-upload-pack", "git-receive-pack", "git-upload-archive": - fmt.Fprintln(ch.Stderr(), "git transport not implemented (M2)") - return protocol.ExitFailure - } - } - argv, err := protocol.Tokenize(cmdline) if err != nil { fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) return protocol.ExitUsage } + if len(argv) > 0 { + switch argv[0] { + case "git-upload-pack", "git-receive-pack", "git-upload-archive": + return s.runGit(ch, user, ext["scope"], argv) + } + } ctx := &control.Ctx{ User: user, Scope: ext["scope"], @@ -207,3 +207,48 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) } return control.Dispatch(ctx, argv) } + +// runGit streams a git transport service after access checks. +func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int { + service := argv[0] + if len(argv) != 2 { + fmt.Fprintf(ch.Stderr(), "usage: %s <path>\n", service) + return protocol.ExitUsage + } + write := service == "git-receive-pack" + + repo, err := s.st.RepoByPath(argv[1]) + if err != nil { + fmt.Fprintln(ch.Stderr(), "repository not found") + return protocol.ExitNotFound + } + grant, err := s.st.AccessRole(repo.ID, user.ID) + if err != nil { + fmt.Fprintln(ch.Stderr(), "internal error") + return protocol.ExitFailure + } + if !policy.CanRead(user, repo, grant) { + // Same answer as nonexistence: private repos must not be enumerable. + fmt.Fprintln(ch.Stderr(), "repository not found") + return protocol.ExitNotFound + } + if !policy.ScopeAllowsGit(scope, repo.Path(), write) { + fmt.Fprintf(ch.Stderr(), "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path()) + return protocol.ExitDenied + } + if write && !policy.CanWrite(user, repo, grant) { + fmt.Fprintf(ch.Stderr(), "write access to %s denied\n", repo.Path()) + return protocol.ExitDenied + } + + dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) + env := []string{ + hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root), + hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), + hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10), + } + if err := gitutil.Transport(service, dir, ch, ch.Stderr(), env); err != nil { + return protocol.ExitFailure + } + return protocol.ExitOK +} new file mode 100644 @@ -0,0 +1,192 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" +) + +type Repo struct { + ID int64 + OwnerKind string // user | org + OwnerID int64 + OwnerName string // resolved for display and disk paths + Name string + Visibility string // public | private + DefaultBranch string + Settings RepoSettings +} + +type RepoSettings struct { + ProtectedBranches []string `json:"protected_branches,omitempty"` + RequireSignedCommits bool `json:"require_signed_commits,omitempty"` +} + +// Path returns the canonical owner/name form. +func (r Repo) Path() string { return r.OwnerName + "/" + r.Name } + +func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) { + res, err := s.DB.Exec( + "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)", + ownerKind, ownerID, name, visibility) + if err != nil { + if isUniqueErr(err) { + return 0, fmt.Errorf("repository %q already exists", name) + } + return 0, err + } + return res.LastInsertId() +} + +// RepoByPath resolves "owner/name". Only user owners exist until orgs land. +func (s *Store) RepoByPath(path string) (Repo, error) { + owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound) + } + var r Repo + var settingsJSON string + err := s.DB.QueryRow(` + SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json + FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id + WHERE u.username = ? AND r.name = ?`, owner, name). + Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON) + if errors.Is(err, sql.ErrNoRows) { + return Repo{}, ErrNotFound + } + if err != nil { + return Repo{}, err + } + if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { + return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err) + } + return r, nil +} + +func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { + raw, err := json.Marshal(settings) + if err != nil { + return err + } + _, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID) + return err +} + +func (s *Store) DeleteRepo(repoID int64) error { + res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// ListReposForUser returns repos the user owns or has an explicit grant on. +func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { + rows, err := s.DB.Query(` + SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json + FROM repos r + JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id + LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? + WHERE r.owner_id = ? OR a.subject_id IS NOT NULL + ORDER BY u.username, r.name`, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Repo + for rows.Next() { + var r Repo + var settingsJSON string + if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// AccessRole returns the explicit grant for userID on repoID ("" if none). +func (s *Store) AccessRole(repoID, userID int64) (string, error) { + var role string + err := s.DB.QueryRow( + "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?", + repoID, userID).Scan(&role) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return role, err +} + +func (s *Store) GrantAccess(repoID, userID int64, role string) error { + _, err := s.DB.Exec(` + INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?) + ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`, + repoID, userID, role) + return err +} + +func (s *Store) RevokeAccess(repoID, userID int64) error { + res, err := s.DB.Exec( + "DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?", + repoID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +type AccessEntry struct { + Username string + Role string +} + +func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) { + rows, err := s.DB.Query(` + SELECT u.username, a.role FROM repo_access a + JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id + WHERE a.repo_id = ? ORDER BY u.username`, repoID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AccessEntry + for rows.Next() { + var e AccessEntry + if err := rows.Scan(&e.Username, &e.Role); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (s *Store) RepoByID(id int64) (Repo, error) { + var r Repo + var settingsJSON string + err := s.DB.QueryRow(` + SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json + FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id + WHERE r.id = ?`, id). + Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON) + if errors.Is(err, sql.ErrNoRows) { + return Repo{}, ErrNotFound + } + if err != nil { + return Repo{}, err + } + if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { + return Repo{}, err + } + return r, nil +}