Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

knotserver/{git,xrpc}: thread sandbox backend through git/xrpc operations

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

author
Anirudh Oppiliappan
committer
Tangled
date (Jun 12, 2026, 12:22 PM +0300) commit 390ef2a1 parent 611dfa2f change-id vtrllwyy
+179 -35
+3
knotserver/git.go
··· 62 62 GitProtocol: r.Header.Get("Git-Protocol"), 63 63 Dir: repoPath, 64 64 Stdout: w, 65 + Sandbox: h.sandbox, 65 66 } 66 67 67 68 serviceName := r.URL.Query().Get("service") ··· 119 120 Dir: repo, 120 121 Stdout: w, 121 122 Stdin: bodyReader, 123 + Sandbox: h.sandbox, 122 124 } 123 125 124 126 w.WriteHeader(http.StatusOK) ··· 166 168 Dir: repo, 167 169 Stdout: w, 168 170 Stdin: bodyReader, 171 + Sandbox: h.sandbox, 169 172 } 170 173 171 174 w.WriteHeader(http.StatusOK)
+15 -1
knotserver/git/cmd.go
··· 6 6 "io" 7 7 "os/exec" 8 8 "strings" 9 + "syscall" 9 10 ) 10 11 11 12 const ( ··· 19 20 args = append(args, extraArgs...) 20 21 21 22 cmd := exec.Command("git", args...) 22 - cmd.Dir = g.path 23 + 24 + if g.sandbox != nil { 25 + var wrapErr error 26 + cmd, wrapErr = g.sandbox.Wrap(g.path, cmd) 27 + if wrapErr != nil { 28 + return nil, fmt.Errorf("sandbox wrap: %w", wrapErr) 29 + } 30 + } else { 31 + cmd.Dir = g.path 32 + } 33 + 34 + if cmd.SysProcAttr == nil { 35 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 36 + } 23 37 24 38 out, err := cmd.Output() 25 39 if err != nil {
+22
knotserver/git/fork.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/url" 8 + "os" 8 9 "os/exec" 9 10 "path/filepath" 10 11 11 12 "github.com/go-git/go-git/v5" 12 13 "github.com/go-git/go-git/v5/config" 13 14 knotconfig "tangled.org/core/knotserver/config" 15 + "tangled.org/core/knotserver/sandbox" 14 16 ) 15 17 16 18 func Fork(repoPath, source string, cfg *knotconfig.Config) error { 19 + return ForkWithSandbox(repoPath, source, cfg, nil) 20 + } 21 + 22 + // ForkWithSandbox clones source into repoPath, optionally wrapping the 23 + // post-clone configure step in sb. The initial clone itself is not sandboxed 24 + // because the target directory doesn't exist yet when the ruleset is applied. 25 + func ForkWithSandbox(repoPath, source string, cfg *knotconfig.Config, sb sandbox.Backend) error { 17 26 u, err := url.Parse(source) 18 27 if err != nil { 19 28 return fmt.Errorf("failed to parse source URL: %w", err) ··· 28 37 return fmt.Errorf("failed to bare clone repository: %w", err) 29 38 } 30 39 40 + // ensure repoPath exists before attempting to sandbox the configure step. 41 + if _, statErr := os.Stat(repoPath); statErr != nil { 42 + return fmt.Errorf("clone did not create %s: %w", repoPath, statErr) 43 + } 44 + 31 45 configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 46 + if sb != nil { 47 + configureCmd, err = sb.Wrap(repoPath, configureCmd) 48 + if err != nil { 49 + return fmt.Errorf("sandbox wrap for git config: %w", err) 50 + } 51 + } else { 52 + configureCmd.Dir = repoPath 53 + } 32 54 if err := configureCmd.Run(); err != nil { 33 55 return fmt.Errorf("failed to configure hidden refs: %w", err) 34 56 }
+13 -3
knotserver/git/git.go
··· 17 17 "github.com/go-git/go-git/v5/config" 18 18 "github.com/go-git/go-git/v5/plumbing" 19 19 "github.com/go-git/go-git/v5/plumbing/object" 20 + "tangled.org/core/knotserver/sandbox" 20 21 ) 21 22 22 23 var ( ··· 28 29 ) 29 30 30 31 type GitRepo struct { 31 - path string 32 - r *git.Repository 33 - h plumbing.Hash 32 + path string 33 + r *git.Repository 34 + h plumbing.Hash 35 + sandbox sandbox.Backend 34 36 } 35 37 36 38 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo ··· 75 77 return nil, fmt.Errorf("opening %s: %w", path, err) 76 78 } 77 79 return &g, nil 80 + } 81 + 82 + // WithSandbox returns a copy of the GitRepo that uses the given sandbox 83 + // backend for all git subprocesses. 84 + func (g *GitRepo) WithSandbox(sb sandbox.Backend) *GitRepo { 85 + cp := *g 86 + cp.sandbox = sb 87 + return &cp 78 88 } 79 89 80 90 func (g *GitRepo) Hash() plumbing.Hash {
+95 -20
knotserver/git/merge.go
··· 108 108 return fmt.Sprintf("merge failed: %s", e.Message) 109 109 } 110 110 111 + // createTemp creates a temporary patch file in the system temp directory. 111 112 func createTemp(data string) (string, error) { 112 - tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 113 + return createTempIn("", data) 114 + } 115 + 116 + // createTempIn creates a temporary patch file in dir (empty = system /tmp). 117 + func createTempIn(dir string, data string) (string, error) { 118 + tmpFile, err := os.CreateTemp(dir, "git-patch-*.patch") 113 119 if err != nil { 114 120 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 115 121 } ··· 150 156 151 157 func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 152 158 var stderr bytes.Buffer 153 - var cmd *exec.Cmd 159 + 160 + // wrapCmd optionally sandboxes a command to g.path. 161 + wrapCmd := func(cmd *exec.Cmd) (*exec.Cmd, error) { 162 + if g.sandbox != nil { 163 + return g.sandbox.Wrap(g.path, cmd) 164 + } 165 + cmd.Dir = g.path 166 + return cmd, nil 167 + } 154 168 155 169 // configure default git user before merge 156 - exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 157 - exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 158 - exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 159 - exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run() 170 + for _, cfgArgs := range [][]string{ 171 + {"-C", g.path, "config", "user.name", opts.CommitterName}, 172 + {"-C", g.path, "config", "user.email", opts.CommitterEmail}, 173 + {"-C", g.path, "config", "advice.mergeConflict", "false"}, 174 + {"-C", g.path, "config", "advice.amWorkDir", "false"}, 175 + } { 176 + cfgCmd, _ := wrapCmd(exec.Command("git", cfgArgs...)) 177 + cfgCmd.Run() //nolint:errcheck // best-effort config 178 + } 160 179 161 180 // if patch is a format-patch, apply using 'git am' 162 181 if opts.FormatPatch { ··· 164 183 } 165 184 166 185 // else, apply using 'git apply' and commit it manually 167 - applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 186 + applyCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "apply", patchFile)) 187 + if err != nil { 188 + return fmt.Errorf("sandbox wrap for git apply: %w", err) 189 + } 168 190 applyCmd.Stderr = &stderr 169 191 if err := applyCmd.Run(); err != nil { 170 192 return fmt.Errorf("patch application failed: %s", stderr.String()) 171 193 } 172 194 173 - stageCmd := exec.Command("git", "-C", g.path, "add", ".") 195 + stderr.Reset() 196 + stageCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "add", ".")) 197 + if err != nil { 198 + return fmt.Errorf("sandbox wrap for git add: %w", err) 199 + } 174 200 if err := stageCmd.Run(); err != nil { 175 201 return fmt.Errorf("failed to stage changes: %w", err) 176 202 } ··· 192 218 commitArgs = append(commitArgs, "-m", opts.CommitBody) 193 219 } 194 220 195 - cmd = exec.Command("git", commitArgs...) 196 - 221 + cmd, err := wrapCmd(exec.Command("git", commitArgs...)) 222 + if err != nil { 223 + return fmt.Errorf("sandbox wrap for git commit: %w", err) 224 + } 225 + stderr.Reset() 197 226 cmd.Stderr = &stderr 198 227 199 228 if err := cmd.Run(); err != nil { ··· 231 260 } 232 261 233 262 func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 234 - tmpPatch, err := createTemp(singlePatch.Raw) 263 + // when sandboxed, create the patch file inside g.path so it is 264 + // within the bound directory and visible to the git subprocess. 265 + patchDir := "" 266 + if g.sandbox != nil { 267 + patchDir = g.path 268 + } 269 + tmpPatch, err := createTempIn(patchDir, singlePatch.Raw) 235 270 if err != nil { 236 271 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err) 237 272 } 238 273 239 274 var stderr bytes.Buffer 240 - cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 275 + rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 276 + var cmd *exec.Cmd 277 + if g.sandbox != nil { 278 + cmd, err = g.sandbox.Wrap(g.path, rawCmd) 279 + if err != nil { 280 + return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err) 281 + } 282 + } else { 283 + rawCmd.Dir = g.path 284 + cmd = rawCmd 285 + } 241 286 cmd.Stderr = &stderr 242 287 243 288 head, err := g.r.Head() ··· 327 372 return val 328 373 } 329 374 330 - patchFile, err := createTemp(patchData) 375 + tmpDir, err := g.cloneTemp(targetBranch) 331 376 if err != nil { 332 377 return &ErrMerge{ 333 378 Message: err.Error(), 334 379 OtherError: err, 335 380 } 336 381 } 337 - defer os.Remove(patchFile) 382 + defer os.RemoveAll(tmpDir) 338 383 339 - tmpDir, err := g.cloneTemp(targetBranch) 384 + // when sandboxed, create the patch file inside tmpDir so it is 385 + // visible to the git subprocess. 386 + patchDir := "" 387 + if g.sandbox != nil { 388 + patchDir = tmpDir 389 + } 390 + patchFile, err := createTempIn(patchDir, patchData) 340 391 if err != nil { 341 392 return &ErrMerge{ 342 393 Message: err.Error(), 343 394 OtherError: err, 344 395 } 345 396 } 346 - defer os.RemoveAll(tmpDir) 397 + defer os.Remove(patchFile) 347 398 348 399 tmpRepo, err := PlainOpen(tmpDir) 349 400 if err != nil { 350 401 return err 351 402 } 403 + if g.sandbox != nil { 404 + tmpRepo = tmpRepo.WithSandbox(g.sandbox) 405 + } 352 406 353 407 result := tmpRepo.applyPatch(patchData, patchFile, mo) 354 408 mergeCheckCache.Set(g, patchData, targetBranch, result) ··· 356 410 } 357 411 358 412 func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 359 - patchFile, err := createTemp(patchData) 413 + tmpDir, err := g.cloneTemp(targetBranch) 360 414 if err != nil { 361 415 return &ErrMerge{ 362 416 Message: err.Error(), 363 417 OtherError: err, 364 418 } 365 419 } 366 - defer os.Remove(patchFile) 420 + defer os.RemoveAll(tmpDir) 367 421 368 - tmpDir, err := g.cloneTemp(targetBranch) 422 + // when sandboxed, create the patch file inside tmpDir so it is 423 + // visible to the git subprocess. 424 + patchDir := "" 425 + if g.sandbox != nil { 426 + patchDir = tmpDir 427 + } 428 + patchFile, err := createTempIn(patchDir, patchData) 369 429 if err != nil { 370 430 return &ErrMerge{ 371 431 Message: err.Error(), 372 432 OtherError: err, 373 433 } 374 434 } 375 - defer os.RemoveAll(tmpDir) 435 + defer os.Remove(patchFile) 376 436 377 437 tmpRepo, err := PlainOpen(tmpDir) 378 438 if err != nil { 379 439 return err 380 440 } 441 + if g.sandbox != nil { 442 + tmpRepo = tmpRepo.WithSandbox(g.sandbox) 443 + } 381 444 382 445 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 383 446 return err 384 447 } 385 448 386 449 pushCmd := exec.Command("git", "-C", tmpDir, "push") 450 + if g.sandbox != nil { 451 + // the push needs access to both tmpDir (source) and g.path (target bare repo). 452 + pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd) 453 + if err != nil { 454 + return &ErrMerge{ 455 + Message: "sandbox wrap for git push failed", 456 + OtherError: err, 457 + } 458 + } 459 + } else { 460 + pushCmd.Dir = tmpDir 461 + } 387 462 if err := pushCmd.Run(); err != nil { 388 463 return &ErrMerge{ 389 464 Message: "failed to push changes to bare repository",
+17 -10
knotserver/git/service/service.go
··· 10 10 "strings" 11 11 "sync" 12 12 "syscall" 13 + 14 + "tangled.org/core/knotserver/sandbox" 13 15 ) 14 16 15 17 // Mostly from charmbracelet/soft-serve and sosedoff/gitkit. ··· 19 21 Dir string 20 22 Stdin io.Reader 21 23 Stdout http.ResponseWriter 24 + Sandbox sandbox.Backend 22 25 } 23 26 24 27 func (c *ServiceCommand) RunService(cmd *exec.Cmd) error { 25 - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 26 - cmd.Dir = c.Dir 27 28 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 29 + 30 + if c.Sandbox != nil { 31 + var wrapErr error 32 + cmd, wrapErr = c.Sandbox.Wrap(c.Dir, cmd) 33 + if wrapErr != nil { 34 + return fmt.Errorf("sandbox wrap: %w", wrapErr) 35 + } 36 + } else { 37 + cmd.Dir = c.Dir 38 + } 39 + 40 + if cmd.SysProcAttr == nil { 41 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 42 + } 28 43 29 44 var stderr bytes.Buffer 30 45 cmd.Stderr = &stderr ··· 101 116 ".", 102 117 }...) 103 118 104 - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 105 - cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 106 - cmd.Dir = c.Dir 107 - 108 119 return c.RunService(cmd) 109 120 } 110 121 ··· 114 125 "--stateless-rpc", 115 126 ".", 116 127 }...) 117 - 118 - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 119 - cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 120 - cmd.Dir = c.Dir 121 128 122 129 return c.RunService(cmd) 123 130 }
+5 -1
knotserver/router.go
··· 14 14 "tangled.org/core/jetstream" 15 15 "tangled.org/core/knotserver/config" 16 16 "tangled.org/core/knotserver/db" 17 + "tangled.org/core/knotserver/sandbox" 17 18 "tangled.org/core/knotserver/xrpc" 18 19 "tangled.org/core/log" 19 20 "tangled.org/core/notifier" ··· 32 33 l *slog.Logger 33 34 n *notifier.Notifier 34 35 resolver *idresolver.Resolver 36 + sandbox sandbox.Backend 35 37 motd []byte 36 38 motdMu sync.RWMutex 37 39 } 38 40 39 - 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) { 41 + 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) { 40 42 h := Knot{ 41 43 c: c, 42 44 db: db, ··· 45 47 jc: jc, 46 48 n: n, 47 49 resolver: resolver, 50 + sandbox: sb, 48 51 motd: defaultMotd, 49 52 } 50 53 ··· 140 143 Notifier: h.n, 141 144 Resolver: h.resolver, 142 145 ServiceAuth: serviceAuth, 146 + Sandbox: h.sandbox, 143 147 } 144 148 145 149 return xrpc.Router()
+1
knotserver/xrpc/create_repo.go
··· 19 19 "tangled.org/core/hook" 20 20 "tangled.org/core/knotserver/git" 21 21 "tangled.org/core/knotserver/repodid" 22 + "tangled.org/core/knotserver/sandbox" 22 23 "tangled.org/core/rbac" 23 24 xrpcerr "tangled.org/core/xrpc/errors" 24 25 )
+3
knotserver/xrpc/merge.go
··· 64 64 fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 65 65 return 66 66 } 67 + if x.Sandbox != nil { 68 + gr = gr.WithSandbox(x.Sandbox) 69 + } 67 70 68 71 mo := git.MergeOptions{} 69 72 if data.AuthorName != nil {
+3
knotserver/xrpc/merge_check.go
··· 49 49 fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 50 50 return 51 51 } 52 + if x.Sandbox != nil { 53 + gr = gr.WithSandbox(x.Sandbox) 54 + } 52 55 53 56 mo := git.MergeOptions{} 54 57 mo.CommitMessage = "merge check"
+2
knotserver/xrpc/xrpc.go
··· 17 17 "tangled.org/core/jetstream" 18 18 "tangled.org/core/knotserver/config" 19 19 "tangled.org/core/knotserver/db" 20 + "tangled.org/core/knotserver/sandbox" 20 21 "tangled.org/core/notifier" 21 22 "tangled.org/core/rbac" 22 23 xrpcerr "tangled.org/core/xrpc/errors" ··· 32 33 Notifier *notifier.Notifier 33 34 Resolver *idresolver.Resolver 34 35 ServiceAuth *serviceauth.ServiceAuth 36 + Sandbox sandbox.Backend 35 37 } 36 38 37 39 func (x *Xrpc) Router() http.Handler {