diff --git a/knotserver/git.go b/knotserver/git.go --- a/knotserver/git.go +++ b/knotserver/git.go @@ -62,6 +62,7 @@ GitProtocol: r.Header.Get("Git-Protocol"), Dir: repoPath, Stdout: w, + Sandbox: h.sandbox, } serviceName := r.URL.Query().Get("service") @@ -119,6 +120,7 @@ Dir: repo, Stdout: w, Stdin: bodyReader, + Sandbox: h.sandbox, } w.WriteHeader(http.StatusOK) @@ -166,6 +168,7 @@ Dir: repo, Stdout: w, Stdin: bodyReader, + Sandbox: h.sandbox, } w.WriteHeader(http.StatusOK) diff --git a/knotserver/router.go b/knotserver/router.go --- a/knotserver/router.go +++ b/knotserver/router.go @@ -14,6 +14,7 @@ "tangled.org/core/jetstream" "tangled.org/core/knotserver/config" "tangled.org/core/knotserver/db" + "tangled.org/core/knotserver/sandbox" "tangled.org/core/knotserver/xrpc" "tangled.org/core/log" "tangled.org/core/notifier" @@ -32,11 +33,12 @@ l *slog.Logger n *notifier.Notifier resolver *idresolver.Resolver + sandbox sandbox.Backend motd []byte motdMu sync.RWMutex } -func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier, resolver *idresolver.Resolver) (http.Handler, error) { +func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier, resolver *idresolver.Resolver, sb sandbox.Backend) (http.Handler, error) { h := Knot{ c: c, db: db, @@ -45,6 +47,7 @@ jc: jc, n: n, resolver: resolver, + sandbox: sb, motd: defaultMotd, } @@ -140,6 +143,7 @@ Notifier: h.n, Resolver: h.resolver, ServiceAuth: serviceAuth, + Sandbox: h.sandbox, } return xrpc.Router() diff --git a/knotserver/git/cmd.go b/knotserver/git/cmd.go --- a/knotserver/git/cmd.go +++ b/knotserver/git/cmd.go @@ -6,6 +6,7 @@ "io" "os/exec" "strings" + "syscall" ) const ( @@ -19,7 +20,20 @@ args = append(args, extraArgs...) cmd := exec.Command("git", args...) - cmd.Dir = g.path + + if g.sandbox != nil { + var wrapErr error + cmd, wrapErr = g.sandbox.Wrap(g.path, cmd) + if wrapErr != nil { + return nil, fmt.Errorf("sandbox wrap: %w", wrapErr) + } + } else { + cmd.Dir = g.path + } + + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } out, err := cmd.Output() if err != nil { diff --git a/knotserver/git/fork.go b/knotserver/git/fork.go --- a/knotserver/git/fork.go +++ b/knotserver/git/fork.go @@ -5,15 +5,24 @@ "fmt" "log/slog" "net/url" + "os" "os/exec" "path/filepath" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" knotconfig "tangled.org/core/knotserver/config" + "tangled.org/core/knotserver/sandbox" ) func Fork(repoPath, source string, cfg *knotconfig.Config) error { + return ForkWithSandbox(repoPath, source, cfg, nil) +} + +// ForkWithSandbox clones source into repoPath, optionally wrapping the +// post-clone configure step in sb. The initial clone itself is not sandboxed +// because the target directory doesn't exist yet when the ruleset is applied. +func ForkWithSandbox(repoPath, source string, cfg *knotconfig.Config, sb sandbox.Backend) error { u, err := url.Parse(source) if err != nil { return fmt.Errorf("failed to parse source URL: %w", err) @@ -28,7 +37,20 @@ return fmt.Errorf("failed to bare clone repository: %w", err) } + // ensure repoPath exists before attempting to sandbox the configure step. + if _, statErr := os.Stat(repoPath); statErr != nil { + return fmt.Errorf("clone did not create %s: %w", repoPath, statErr) + } + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") + if sb != nil { + configureCmd, err = sb.Wrap(repoPath, configureCmd) + if err != nil { + return fmt.Errorf("sandbox wrap for git config: %w", err) + } + } else { + configureCmd.Dir = repoPath + } if err := configureCmd.Run(); err != nil { return fmt.Errorf("failed to configure hidden refs: %w", err) } diff --git a/knotserver/git/git.go b/knotserver/git/git.go --- a/knotserver/git/git.go +++ b/knotserver/git/git.go @@ -17,6 +17,7 @@ "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "tangled.org/core/knotserver/sandbox" ) var ( @@ -28,9 +29,10 @@ ) type GitRepo struct { - path string - r *git.Repository - h plumbing.Hash + path string + r *git.Repository + h plumbing.Hash + sandbox sandbox.Backend } // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo @@ -75,6 +77,14 @@ return nil, fmt.Errorf("opening %s: %w", path, err) } return &g, nil +} + +// WithSandbox returns a copy of the GitRepo that uses the given sandbox +// backend for all git subprocesses. +func (g *GitRepo) WithSandbox(sb sandbox.Backend) *GitRepo { + cp := *g + cp.sandbox = sb + return &cp } func (g *GitRepo) Hash() plumbing.Hash { diff --git a/knotserver/git/merge.go b/knotserver/git/merge.go --- a/knotserver/git/merge.go +++ b/knotserver/git/merge.go @@ -108,8 +108,14 @@ return fmt.Sprintf("merge failed: %s", e.Message) } +// createTemp creates a temporary patch file in the system temp directory. func createTemp(data string) (string, error) { - tmpFile, err := os.CreateTemp("", "git-patch-*.patch") + return createTempIn("", data) +} + +// createTempIn creates a temporary patch file in dir (empty = system /tmp). +func createTempIn(dir string, data string) (string, error) { + tmpFile, err := os.CreateTemp(dir, "git-patch-*.patch") if err != nil { return "", fmt.Errorf("failed to create temporary patch file: %w", err) } @@ -150,13 +156,26 @@ func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { var stderr bytes.Buffer - var cmd *exec.Cmd + + // wrapCmd optionally sandboxes a command to g.path. + wrapCmd := func(cmd *exec.Cmd) (*exec.Cmd, error) { + if g.sandbox != nil { + return g.sandbox.Wrap(g.path, cmd) + } + cmd.Dir = g.path + return cmd, nil + } // configure default git user before merge - exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() - exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() - exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() - exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run() + for _, cfgArgs := range [][]string{ + {"-C", g.path, "config", "user.name", opts.CommitterName}, + {"-C", g.path, "config", "user.email", opts.CommitterEmail}, + {"-C", g.path, "config", "advice.mergeConflict", "false"}, + {"-C", g.path, "config", "advice.amWorkDir", "false"}, + } { + cfgCmd, _ := wrapCmd(exec.Command("git", cfgArgs...)) + cfgCmd.Run() //nolint:errcheck // best-effort config + } // if patch is a format-patch, apply using 'git am' if opts.FormatPatch { @@ -164,13 +183,20 @@ } // else, apply using 'git apply' and commit it manually - applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) + applyCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "apply", patchFile)) + if err != nil { + return fmt.Errorf("sandbox wrap for git apply: %w", err) + } applyCmd.Stderr = &stderr if err := applyCmd.Run(); err != nil { return fmt.Errorf("patch application failed: %s", stderr.String()) } - stageCmd := exec.Command("git", "-C", g.path, "add", ".") + stderr.Reset() + stageCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "add", ".")) + if err != nil { + return fmt.Errorf("sandbox wrap for git add: %w", err) + } if err := stageCmd.Run(); err != nil { return fmt.Errorf("failed to stage changes: %w", err) } @@ -192,8 +218,11 @@ commitArgs = append(commitArgs, "-m", opts.CommitBody) } - cmd = exec.Command("git", commitArgs...) - + cmd, err := wrapCmd(exec.Command("git", commitArgs...)) + if err != nil { + return fmt.Errorf("sandbox wrap for git commit: %w", err) + } + stderr.Reset() cmd.Stderr = &stderr if err := cmd.Run(); err != nil { @@ -231,13 +260,29 @@ } func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { - tmpPatch, err := createTemp(singlePatch.Raw) + // when sandboxed, create the patch file inside g.path so it is + // within the bound directory and visible to the git subprocess. + patchDir := "" + if g.sandbox != nil { + patchDir = g.path + } + tmpPatch, err := createTempIn(patchDir, singlePatch.Raw) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err) } var stderr bytes.Buffer - cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) + rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch) + var cmd *exec.Cmd + if g.sandbox != nil { + cmd, err = g.sandbox.Wrap(g.path, rawCmd) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err) + } + } else { + rawCmd.Dir = g.path + cmd = rawCmd + } cmd.Stderr = &stderr head, err := g.r.Head() @@ -327,15 +372,6 @@ return val } - patchFile, err := createTemp(patchData) - if err != nil { - return &ErrMerge{ - Message: err.Error(), - OtherError: err, - } - } - defer os.Remove(patchFile) - tmpDir, err := g.cloneTemp(targetBranch) if err != nil { return &ErrMerge{ @@ -345,9 +381,27 @@ } defer os.RemoveAll(tmpDir) + // when sandboxed, create the patch file inside tmpDir so it is + // visible to the git subprocess. + patchDir := "" + if g.sandbox != nil { + patchDir = tmpDir + } + patchFile, err := createTempIn(patchDir, patchData) + if err != nil { + return &ErrMerge{ + Message: err.Error(), + OtherError: err, + } + } + defer os.Remove(patchFile) + tmpRepo, err := PlainOpen(tmpDir) if err != nil { return err + } + if g.sandbox != nil { + tmpRepo = tmpRepo.WithSandbox(g.sandbox) } result := tmpRepo.applyPatch(patchData, patchFile, mo) @@ -356,15 +410,6 @@ } func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { - patchFile, err := createTemp(patchData) - if err != nil { - return &ErrMerge{ - Message: err.Error(), - OtherError: err, - } - } - defer os.Remove(patchFile) - tmpDir, err := g.cloneTemp(targetBranch) if err != nil { return &ErrMerge{ @@ -374,9 +419,27 @@ } defer os.RemoveAll(tmpDir) + // when sandboxed, create the patch file inside tmpDir so it is + // visible to the git subprocess. + patchDir := "" + if g.sandbox != nil { + patchDir = tmpDir + } + patchFile, err := createTempIn(patchDir, patchData) + if err != nil { + return &ErrMerge{ + Message: err.Error(), + OtherError: err, + } + } + defer os.Remove(patchFile) + tmpRepo, err := PlainOpen(tmpDir) if err != nil { return err + } + if g.sandbox != nil { + tmpRepo = tmpRepo.WithSandbox(g.sandbox) } if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { @@ -384,6 +447,18 @@ } pushCmd := exec.Command("git", "-C", tmpDir, "push") + if g.sandbox != nil { + // the push needs access to both tmpDir (source) and g.path (target bare repo). + pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd) + if err != nil { + return &ErrMerge{ + Message: "sandbox wrap for git push failed", + OtherError: err, + } + } + } else { + pushCmd.Dir = tmpDir + } if err := pushCmd.Run(); err != nil { return &ErrMerge{ Message: "failed to push changes to bare repository", diff --git a/knotserver/xrpc/create_repo.go b/knotserver/xrpc/create_repo.go --- a/knotserver/xrpc/create_repo.go +++ b/knotserver/xrpc/create_repo.go @@ -19,6 +19,7 @@ "tangled.org/core/hook" "tangled.org/core/knotserver/git" "tangled.org/core/knotserver/repodid" + "tangled.org/core/knotserver/sandbox" "tangled.org/core/rbac" xrpcerr "tangled.org/core/xrpc/errors" ) diff --git a/knotserver/xrpc/merge.go b/knotserver/xrpc/merge.go --- a/knotserver/xrpc/merge.go +++ b/knotserver/xrpc/merge.go @@ -64,6 +64,9 @@ fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) return } + if x.Sandbox != nil { + gr = gr.WithSandbox(x.Sandbox) + } mo := git.MergeOptions{} if data.AuthorName != nil { diff --git a/knotserver/xrpc/merge_check.go b/knotserver/xrpc/merge_check.go --- a/knotserver/xrpc/merge_check.go +++ b/knotserver/xrpc/merge_check.go @@ -49,6 +49,9 @@ fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) return } + if x.Sandbox != nil { + gr = gr.WithSandbox(x.Sandbox) + } mo := git.MergeOptions{} mo.CommitMessage = "merge check" diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -17,6 +17,7 @@ "tangled.org/core/jetstream" "tangled.org/core/knotserver/config" "tangled.org/core/knotserver/db" + "tangled.org/core/knotserver/sandbox" "tangled.org/core/notifier" "tangled.org/core/rbac" xrpcerr "tangled.org/core/xrpc/errors" @@ -32,6 +33,7 @@ Notifier *notifier.Notifier Resolver *idresolver.Resolver ServiceAuth *serviceauth.ServiceAuth + Sandbox sandbox.Backend } func (x *Xrpc) Router() http.Handler { diff --git a/knotserver/git/service/service.go b/knotserver/git/service/service.go --- a/knotserver/git/service/service.go +++ b/knotserver/git/service/service.go @@ -10,6 +10,8 @@ "strings" "sync" "syscall" + + "tangled.org/core/knotserver/sandbox" ) // Mostly from charmbracelet/soft-serve and sosedoff/gitkit. @@ -19,12 +21,25 @@ Dir string Stdin io.Reader Stdout http.ResponseWriter + Sandbox sandbox.Backend } func (c *ServiceCommand) RunService(cmd *exec.Cmd) error { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Dir = c.Dir cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) + + if c.Sandbox != nil { + var wrapErr error + cmd, wrapErr = c.Sandbox.Wrap(c.Dir, cmd) + if wrapErr != nil { + return fmt.Errorf("sandbox wrap: %w", wrapErr) + } + } else { + cmd.Dir = c.Dir + } + + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } var stderr bytes.Buffer cmd.Stderr = &stderr @@ -101,10 +116,6 @@ ".", }...) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) - cmd.Dir = c.Dir - return c.RunService(cmd) } @@ -114,10 +125,6 @@ "--stateless-rpc", ".", }...) - - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) - cmd.Dir = c.Dir return c.RunService(cmd) }