Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

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

feat: add post-receive hooks sandboxed via kefka

Implement automatic hook execution after a successful push: when started with
-allow-hooks, objgitd runs .objgit/hooks/receive-pack from the pushed commit
in a restricted shell (kefka virtual bash). The hook sees a read-only view of
the commit tree at /src and writable scratch at /tmp. Execution is async
post-response so hooks cannot reject a push. Only coreutils commands available
(cat, grep, ls, head, tail, sort, sha256sum, etc.) — no arbitrary binaries,
network, or git command.

Refs before/after push are snapshotted to detect branch changes since
transport.ReceivePack does not report them. One hook runs per created/updated
branch; deletions are skipped. Output and exit status logged to slog only.

New packages:
- internal/treefs: lazy read-only billy.Filesystem view of a git tree
(blobs fetched on open, no checkout to disk)
- internal/mountfs: path-prefix composite FS routing /src and /tmp to
separate mounted filesystems
- internal/kefkash: vendored copy of kefka's billysh handler wiring
(adapted to allow writes so /tmp redirections work)

Includes: daemon integration, flags (-allow-hooks, -hook-timeout), tests
(treefs unit tests, diffRefs, e2e push tests), example hook with regression
test, and CLAUDE.md architecture documentation.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso (May 28, 2026, 10:15 PM EDT) ecd47c2c 4ae20f98

+1685 -31
+52
.objgit/hooks/receive-pack
··· 1 + #!/usr/bin/env bash 2 + # 3 + # Example objgitd receive-pack hook. 4 + # 5 + # objgitd runs this script after a successful push when started with 6 + # -allow-hooks. It does NOT run on a normal `git` server — it only has meaning 7 + # when this repository is served by objgitd. 8 + # 9 + # Execution environment (see CLAUDE.md "Push hooks"): 10 + # * It runs in a kefka virtual shell, NOT a real OS shell. Only kefka's 11 + # built-in commands are available (coreutils: cat, ls, echo, printf, head, 12 + # tail, cut, sort, uniq, wc, sha256sum, grep, ...). There is no PATH to 13 + # system binaries, no network, and no `git`. 14 + # * /src is a READ-ONLY checkout of the pushed commit. The working directory 15 + # starts here. 16 + # * /tmp is the only writable location (also $HOME and $TMPDIR). Writing 17 + # anywhere else fails; a redirect into /src aborts the script. 18 + # * The hook runs asynchronously after the push response, so it cannot reject 19 + # a push. Its stdout/stderr and exit status are written to the server log 20 + # only — the person pushing never sees them. 21 + # * One run happens per created or updated branch. Branch deletions are 22 + # skipped. 23 + # 24 + # These variables describe the ref that triggered this run: 25 + # OBJGIT_REPO repository path, e.g. /myproject.git 26 + # OBJGIT_SERVICE always "receive-pack" 27 + # OBJGIT_REF full ref name, e.g. refs/heads/main 28 + # OBJGIT_BRANCH short branch name, e.g. main 29 + # OBJGIT_OLD_SHA previous tip (all zeros when the branch was created) 30 + # OBJGIT_NEW_SHA new tip 31 + # git's usual "<old> <new> <ref>" line is also fed on stdin. 32 + 33 + echo "push to ${OBJGIT_REPO} ${OBJGIT_REF}: ${OBJGIT_OLD_SHA} -> ${OBJGIT_NEW_SHA}" 34 + 35 + # /src is the checkout of the new commit. List what landed at the top level. 36 + echo "top-level contents:" 37 + ls /src 38 + 39 + # Read a file out of the push and act on it. 40 + if [ -f /src/go.mod ]; then 41 + module="$(head -n 1 /src/go.mod | cut -d' ' -f2)" 42 + echo "go module: ${module}" 43 + fi 44 + 45 + # Scratch work goes in /tmp. Here we record a tiny build manifest. 46 + manifest=/tmp/manifest.txt 47 + echo "ref ${OBJGIT_REF}" > "${manifest}" 48 + echo "sha ${OBJGIT_NEW_SHA}" >> "${manifest}" 49 + echo "manifest (${manifest}):" 50 + cat "${manifest}" 51 + 52 + echo "hook done"
+24
CLAUDE.md
··· 42 42 43 43 2. **No-op closers everywhere.** `transport.UploadPack`/`ReceivePack` call `Close` on the reader (and sometimes the writer) between negotiation rounds. The git:// socket can't survive that, and the HTTP `ResponseWriter` doesn't implement `Close`. Wrap with `io.NopCloser` (reader) and `ioutil.WriteNopCloser` from `go-git/v6/utils/ioutil` (writer). 44 44 45 + ### Push hooks (`hooks.go`, sandboxed via kefka) 46 + 47 + When `-allow-hooks` is set, a successful `receive-pack` runs the repository's 48 + `.objgit/hooks/receive-pack` script. Because `transport.ReceivePack` does not 49 + report which refs it changed, `receivePack` (the wrapper both transports call 50 + instead of `transport.ReceivePack` directly) snapshots branch refs before and 51 + after and diffs them (`snapshotRefs`/`diffRefs`). For each created/updated 52 + branch it spawns an **async** goroutine (tracked by `daemon.hookWG`, drained on 53 + shutdown) — hooks run after the client already has its response and **cannot 54 + reject a push**. Deleted branches are skipped. 55 + 56 + The script is read from the pushed commit's tree, so a branch carries its own 57 + hook. It runs in a **kefka** virtual shell (`tangled.org/xeiaso.net/kefka`), 58 + which is *not* an OS sandbox: it is an `mvdan.cc/sh` interpreter wired to a 59 + `billy.Filesystem` plus a fixed registry of commands (coreutils only here). The 60 + sandbox filesystem is an `internal/mountfs` composite of `/src` (a lazy 61 + read-only `internal/treefs` view of the commit tree — blobs fetched on open, no 62 + checkout to disk) and `/tmp` (a writable `memfs` for scratch; `HOME`/`TMPDIR` 63 + point here). Writing anywhere but `/tmp` fails — and a redirect into `/src` 64 + aborts the script. Hook stdout/stderr and exit status are logged via `slog` 65 + only, never relayed to the pusher. `internal/kefkash` vendors kefka's 66 + unexported `billysh` handler wiring (its `OpenHandler` is adapted to permit 67 + writes so `/tmp` redirections work; the filesystem enforces read-only `/src`). 68 + 45 69 ### `internal/s3fs` — billy.Filesystem on Tigris 46 70 47 71 Vendored from Austin Poor's s3fs and adapted to **billy v6** and the Tigris `storage-go` client. Treats an S3 bucket as a filesystem so go-git's `filesystem.NewStorage` can store loose objects and packs against it.
+76
cmd/objgitd/example_hook_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "net" 7 + "os" 8 + "os/exec" 9 + "path/filepath" 10 + "strings" 11 + "testing" 12 + "time" 13 + 14 + "github.com/go-git/go-billy/v6/memfs" 15 + "github.com/go-git/go-git/v6/plumbing/transport" 16 + ) 17 + 18 + // TestExampleHookRuns pushes the repository's own example hook 19 + // (.objgit/hooks/receive-pack, with a go.mod present) and asserts it runs to 20 + // completion in the sandbox. This guards the shipped example against bit-rot: 21 + // if the hook ever uses something kefka cannot run, this test fails. 22 + func TestExampleHookRuns(t *testing.T) { 23 + if _, err := exec.LookPath("git"); err != nil { 24 + t.Skip("git not installed") 25 + } 26 + 27 + example, err := os.ReadFile(filepath.Join("..", "..", ".objgit", "hooks", "receive-pack")) 28 + if err != nil { 29 + t.Fatalf("read example hook: %v", err) 30 + } 31 + 32 + var logBuf syncBuffer 33 + prev := slog.Default() 34 + slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) 35 + defer slog.SetDefault(prev) 36 + 37 + fs := memfs.New() 38 + d := &daemon{ 39 + fs: fs, 40 + loader: transport.NewFilesystemLoader(fs, false), 41 + allowPush: true, 42 + allowHooks: true, 43 + hookTimeout: 30 * time.Second, 44 + } 45 + ctx, cancel := context.WithCancel(context.Background()) 46 + defer cancel() 47 + ln, err := net.Listen("tcp", "127.0.0.1:0") 48 + if err != nil { 49 + t.Fatalf("listen: %v", err) 50 + } 51 + go func() { _ = d.Serve(ctx, ln) }() 52 + 53 + remote := "git://" + ln.Addr().String() + "/example.git" 54 + work := t.TempDir() 55 + runGit(t, work, "init", "-b", "main") 56 + runGit(t, work, "config", "user.email", "test@example.com") 57 + runGit(t, work, "config", "user.name", "Test") 58 + writeFile(t, filepath.Join(work, "go.mod"), "module example.test/thing\n\ngo 1.26\n") 59 + writeFile(t, filepath.Join(work, ".objgit", "hooks", "receive-pack"), string(example)) 60 + runGit(t, work, "add", ".") 61 + runGit(t, work, "commit", "-m", "example") 62 + runGit(t, work, "push", remote, "main") 63 + 64 + waitForLog(t, &logBuf, "hook: finished", 30*time.Second) 65 + 66 + logs := logBuf.String() 67 + if strings.Contains(logs, "with errors") { 68 + t.Fatalf("example hook errored; logs:\n%s", logs) 69 + } 70 + for _, want := range []string{"go module: example.test/thing", "hook done", "manifest"} { 71 + if !strings.Contains(logs, want) { 72 + t.Errorf("example hook output missing %q; logs:\n%s", want, logs) 73 + } 74 + } 75 + t.Logf("logs:\n%s", logs) 76 + }
+8 -1
cmd/objgitd/git_protocol.go
··· 9 9 "net" 10 10 "net/url" 11 11 "strings" 12 + "sync" 12 13 "time" 13 14 14 15 "github.com/go-git/go-billy/v6" ··· 47 48 fs billy.Filesystem 48 49 loader transport.Loader 49 50 allowPush bool 51 + 52 + // allowHooks gates running .objgit/hooks/receive-pack after a push. 53 + allowHooks bool 54 + hookTimeout time.Duration 55 + // hookWG tracks in-flight async hooks so shutdown can drain them. 56 + hookWG sync.WaitGroup 50 57 } 51 58 52 59 // Serve accepts connections on l until ctx is cancelled or Accept fails. ··· 135 142 _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname)) 136 143 return fmt.Errorf("opening %q for push: %w", req.Pathname, err) 137 144 } 138 - return transport.ReceivePack(ctx, streamingStorer{Storer: st}, r, conn, &transport.ReceivePackRequest{ 145 + return d.receivePack(ctx, streamingStorer{Storer: st}, st, req.Pathname, r, conn, &transport.ReceivePackRequest{ 139 146 GitProtocol: gitProtocol, 140 147 }) 141 148
+230
cmd/objgitd/hooks.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "io" 8 + "log/slog" 9 + "strings" 10 + 11 + "github.com/go-git/go-billy/v6" 12 + "github.com/go-git/go-billy/v6/memfs" 13 + "github.com/go-git/go-git/v6/plumbing" 14 + "github.com/go-git/go-git/v6/plumbing/object" 15 + "github.com/go-git/go-git/v6/plumbing/transport" 16 + "github.com/go-git/go-git/v6/storage" 17 + "mvdan.cc/sh/v3/expand" 18 + "mvdan.cc/sh/v3/interp" 19 + "mvdan.cc/sh/v3/syntax" 20 + "tangled.org/xeiaso.net/kefka/command/registry" 21 + "tangled.org/xeiaso.net/kefka/command/registry/coreutils" 22 + "tangled.org/xeiaso.net/objgit/internal/kefkash" 23 + "tangled.org/xeiaso.net/objgit/internal/mountfs" 24 + "tangled.org/xeiaso.net/objgit/internal/treefs" 25 + ) 26 + 27 + // refUpdate records a single branch ref change observed across a receive-pack. 28 + // A zero Old means the branch was created; a zero New means it was deleted. 29 + type refUpdate struct { 30 + Name plumbing.ReferenceName 31 + Old plumbing.Hash 32 + New plumbing.Hash 33 + } 34 + 35 + // snapshotRefs returns the current hash of every branch ref in st. go-git's 36 + // transport.ReceivePack does not report which refs it changed, so we diff a 37 + // snapshot taken before the push against one taken after. 38 + func snapshotRefs(st storage.Storer) (map[plumbing.ReferenceName]plumbing.Hash, error) { 39 + it, err := st.IterReferences() 40 + if err != nil { 41 + return nil, err 42 + } 43 + defer it.Close() 44 + 45 + out := map[plumbing.ReferenceName]plumbing.Hash{} 46 + err = it.ForEach(func(r *plumbing.Reference) error { 47 + if r.Type() == plumbing.HashReference && r.Name().IsBranch() { 48 + out[r.Name()] = r.Hash() 49 + } 50 + return nil 51 + }) 52 + if err != nil { 53 + return nil, err 54 + } 55 + return out, nil 56 + } 57 + 58 + // diffRefs computes the branch ref changes between two snapshots. 59 + func diffRefs(before, after map[plumbing.ReferenceName]plumbing.Hash) []refUpdate { 60 + var updates []refUpdate 61 + for name, newHash := range after { 62 + oldHash, ok := before[name] 63 + switch { 64 + case !ok: 65 + updates = append(updates, refUpdate{Name: name, Old: plumbing.ZeroHash, New: newHash}) 66 + case oldHash != newHash: 67 + updates = append(updates, refUpdate{Name: name, Old: oldHash, New: newHash}) 68 + } 69 + } 70 + for name, oldHash := range before { 71 + if _, ok := after[name]; !ok { 72 + updates = append(updates, refUpdate{Name: name, Old: oldHash, New: plumbing.ZeroHash}) 73 + } 74 + } 75 + return updates 76 + } 77 + 78 + // receivePack runs transport.ReceivePack and, when hooks are enabled, fires the 79 + // repository's receive-pack hook for each updated branch once the push succeeds. 80 + // rpStorer is what ReceivePack writes through (the git:// path hides the 81 + // PackfileWriter capability via streamingStorer); readStorer is the underlying 82 + // storer used for ref snapshots and hook checkouts. 83 + func (d *daemon) receivePack(ctx context.Context, rpStorer, readStorer storage.Storer, repoPath string, r io.ReadCloser, w io.WriteCloser, req *transport.ReceivePackRequest) error { 84 + var before map[plumbing.ReferenceName]plumbing.Hash 85 + if d.allowHooks { 86 + var err error 87 + if before, err = snapshotRefs(readStorer); err != nil { 88 + slog.Warn("hook: ref snapshot before push failed", "path", repoPath, "err", err) 89 + } 90 + } 91 + 92 + if err := transport.ReceivePack(ctx, rpStorer, r, w, req); err != nil { 93 + return err 94 + } 95 + if !d.allowHooks { 96 + return nil 97 + } 98 + 99 + after, err := snapshotRefs(readStorer) 100 + if err != nil { 101 + slog.Error("hook: ref snapshot after push failed", "path", repoPath, "err", err) 102 + return nil 103 + } 104 + 105 + updates := diffRefs(before, after) 106 + if len(updates) == 0 { 107 + return nil 108 + } 109 + 110 + d.hookWG.Add(1) 111 + go func() { 112 + defer d.hookWG.Done() 113 + d.runHooks(repoPath, "receive-pack", readStorer, updates) 114 + }() 115 + return nil 116 + } 117 + 118 + // runHooks executes the receive-pack hook once per non-deleted branch update. 119 + func (d *daemon) runHooks(repoPath, service string, st storage.Storer, updates []refUpdate) { 120 + for _, u := range updates { 121 + if u.New.IsZero() { 122 + continue // branch deletion: nothing to check out 123 + } 124 + d.runHook(repoPath, service, st, u) 125 + } 126 + } 127 + 128 + // runHook looks up .objgit/hooks/<service> in the updated commit's tree and, if 129 + // present, runs it in a kefka shell with /src bound to a read-only view of that 130 + // tree and /tmp to writable scratch. Output and exit status are logged only. 131 + func (d *daemon) runHook(repoPath, service string, st storage.Storer, u refUpdate) { 132 + log := slog.With("repo", repoPath, "service", service, "ref", u.Name.String(), "sha", u.New.String()) 133 + 134 + commit, err := object.GetCommit(st, u.New) 135 + if err != nil { 136 + log.Error("hook: load commit", "err", err) 137 + return 138 + } 139 + tree, err := commit.Tree() 140 + if err != nil { 141 + log.Error("hook: load tree", "err", err) 142 + return 143 + } 144 + 145 + hookFile, err := tree.File(".objgit/hooks/" + service) 146 + if err != nil { 147 + log.Debug("hook: no hook file in pushed tree") 148 + return 149 + } 150 + script, err := hookFile.Contents() 151 + if err != nil { 152 + log.Error("hook: read hook script", "err", err) 153 + return 154 + } 155 + 156 + fsys := mountfs.New(map[string]billy.Filesystem{ 157 + "src": treefs.New(tree), 158 + "tmp": memfs.New(), 159 + }) 160 + 161 + ctx, cancel := context.WithTimeout(context.Background(), d.hookTimeout) 162 + defer cancel() 163 + 164 + var outBuf, errBuf bytes.Buffer 165 + 166 + reg := registry.New() 167 + coreutils.Register(reg) 168 + if err := reg.Chdir(fsys, "/src"); err != nil { 169 + log.Error("hook: chdir /src", "err", err) 170 + return 171 + } 172 + 173 + env := expand.ListEnviron( 174 + "HOME=/tmp", 175 + "PWD=/src", 176 + "TMPDIR=/tmp", 177 + "IFS= \t\n", 178 + "PATH=/usr/bin:/bin", 179 + "KEFKA=1", 180 + "OBJGIT_REPO="+repoPath, 181 + "OBJGIT_SERVICE="+service, 182 + "OBJGIT_REF="+u.Name.String(), 183 + "OBJGIT_BRANCH="+u.Name.Short(), 184 + "OBJGIT_OLD_SHA="+u.Old.String(), 185 + "OBJGIT_NEW_SHA="+u.New.String(), 186 + ) 187 + // Mirror git's post-receive stdin: "<old> <new> <ref>\n". 188 + stdin := strings.NewReader(u.Old.String() + " " + u.New.String() + " " + u.Name.String() + "\n") 189 + 190 + var sh *interp.Runner 191 + middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 192 + return func(ctx context.Context, args []string) error { 193 + return reg.Exec(ctx, fsys, sh, args) 194 + } 195 + } 196 + sh, err = interp.New( 197 + interp.Env(env), 198 + interp.StdIO(stdin, &outBuf, &errBuf), 199 + interp.ExecHandlers(middleware), 200 + interp.CallHandler(kefkash.CallHandler(reg, fsys, &outBuf, &errBuf)), 201 + interp.StatHandler(kefkash.FsysStatHandler(reg, fsys)), 202 + interp.OpenHandler(kefkash.FsysOpenHandler(reg, fsys)), 203 + interp.ReadDirHandler2(kefkash.FsysReadDirHandler(reg, fsys)), 204 + ) 205 + if err != nil { 206 + log.Error("hook: build shell", "err", err) 207 + return 208 + } 209 + 210 + prog, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(script), service) 211 + if err != nil { 212 + log.Error("hook: parse script", "err", err) 213 + return 214 + } 215 + 216 + log.Info("hook: running") 217 + runErr := sh.Run(ctx, prog) 218 + 219 + var exit interp.ExitStatus 220 + isExit := errors.As(runErr, &exit) 221 + attrs := []any{"exit", int(exit), "stdout", outBuf.String(), "stderr", errBuf.String()} 222 + if runErr != nil { 223 + if !isExit { 224 + attrs = append(attrs, "err", runErr) 225 + } 226 + log.Error("hook: finished with errors", attrs...) 227 + return 228 + } 229 + log.Info("hook: finished", attrs...) 230 + }
+241
cmd/objgitd/hooks_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "log/slog" 7 + "net" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "strings" 12 + "sync" 13 + "testing" 14 + "time" 15 + 16 + "github.com/go-git/go-billy/v6/memfs" 17 + "github.com/go-git/go-git/v6/plumbing" 18 + "github.com/go-git/go-git/v6/plumbing/transport" 19 + ) 20 + 21 + func TestDiffRefs(t *testing.T) { 22 + main := plumbing.NewBranchReferenceName("main") 23 + dev := plumbing.NewBranchReferenceName("dev") 24 + h1 := plumbing.NewHash("1111111111111111111111111111111111111111") 25 + h2 := plumbing.NewHash("2222222222222222222222222222222222222222") 26 + 27 + tests := []struct { 28 + name string 29 + before map[plumbing.ReferenceName]plumbing.Hash 30 + after map[plumbing.ReferenceName]plumbing.Hash 31 + want []refUpdate 32 + }{ 33 + { 34 + name: "created", 35 + before: map[plumbing.ReferenceName]plumbing.Hash{}, 36 + after: map[plumbing.ReferenceName]plumbing.Hash{main: h1}, 37 + want: []refUpdate{{Name: main, Old: plumbing.ZeroHash, New: h1}}, 38 + }, 39 + { 40 + name: "updated", 41 + before: map[plumbing.ReferenceName]plumbing.Hash{main: h1}, 42 + after: map[plumbing.ReferenceName]plumbing.Hash{main: h2}, 43 + want: []refUpdate{{Name: main, Old: h1, New: h2}}, 44 + }, 45 + { 46 + name: "deleted", 47 + before: map[plumbing.ReferenceName]plumbing.Hash{main: h1, dev: h2}, 48 + after: map[plumbing.ReferenceName]plumbing.Hash{main: h1}, 49 + want: []refUpdate{{Name: dev, Old: h2, New: plumbing.ZeroHash}}, 50 + }, 51 + { 52 + name: "unchanged", 53 + before: map[plumbing.ReferenceName]plumbing.Hash{main: h1}, 54 + after: map[plumbing.ReferenceName]plumbing.Hash{main: h1}, 55 + want: nil, 56 + }, 57 + } 58 + 59 + for _, tt := range tests { 60 + t.Run(tt.name, func(t *testing.T) { 61 + got := diffRefs(tt.before, tt.after) 62 + if len(got) != len(tt.want) { 63 + t.Fatalf("diffRefs = %v, want %v", got, tt.want) 64 + } 65 + for i, u := range got { 66 + if u != tt.want[i] { 67 + t.Errorf("update[%d] = %+v, want %+v", i, u, tt.want[i]) 68 + } 69 + } 70 + }) 71 + } 72 + } 73 + 74 + // syncBuffer is a goroutine-safe buffer for capturing slog output while the 75 + // server and an async hook write concurrently. 76 + type syncBuffer struct { 77 + mu sync.Mutex 78 + buf bytes.Buffer 79 + } 80 + 81 + func (b *syncBuffer) Write(p []byte) (int, error) { 82 + b.mu.Lock() 83 + defer b.mu.Unlock() 84 + return b.buf.Write(p) 85 + } 86 + 87 + func (b *syncBuffer) String() string { 88 + b.mu.Lock() 89 + defer b.mu.Unlock() 90 + return b.buf.String() 91 + } 92 + 93 + // TestReceivePackHook pushes a repo carrying .objgit/hooks/receive-pack and 94 + // asserts the hook runs in the sandbox: it reads /src, writes scratch to /tmp, 95 + // and cannot write to the read-only /src. 96 + func TestReceivePackHook(t *testing.T) { 97 + if _, err := exec.LookPath("git"); err != nil { 98 + t.Skip("git not installed") 99 + } 100 + 101 + var logBuf syncBuffer 102 + prev := slog.Default() 103 + slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) 104 + defer slog.SetDefault(prev) 105 + 106 + fs := memfs.New() 107 + d := &daemon{ 108 + fs: fs, 109 + loader: transport.NewFilesystemLoader(fs, false), 110 + allowPush: true, 111 + allowHooks: true, 112 + hookTimeout: 30 * time.Second, 113 + } 114 + 115 + ctx, cancel := context.WithCancel(context.Background()) 116 + defer cancel() 117 + 118 + ln, err := net.Listen("tcp", "127.0.0.1:0") 119 + if err != nil { 120 + t.Fatalf("listen: %v", err) 121 + } 122 + go func() { _ = d.Serve(ctx, ln) }() 123 + 124 + remote := "git://" + ln.Addr().String() + "/hooked.git" 125 + 126 + work := t.TempDir() 127 + runGit(t, work, "init", "-b", "main") 128 + runGit(t, work, "config", "user.email", "test@example.com") 129 + runGit(t, work, "config", "user.name", "Test") 130 + 131 + // The hook reads /src (cwd), writes scratch to /tmp, then attempts a write 132 + // into the read-only /src. The final write aborts the shell with a 133 + // read-only error, so WROTE_SRC must never print. 134 + hook := strings.Join([]string{ 135 + "cat README.md", 136 + "echo built > /tmp/out", 137 + "cat /tmp/out", 138 + "echo nope > /src/nope.txt", 139 + "echo WROTE_SRC", 140 + }, "\n") + "\n" 141 + writeFile(t, filepath.Join(work, "README.md"), "hello from repo\n") 142 + writeFile(t, filepath.Join(work, ".objgit", "hooks", "receive-pack"), hook) 143 + runGit(t, work, "add", ".") 144 + runGit(t, work, "commit", "-m", "with hook") 145 + 146 + runGit(t, work, "push", remote, "main") 147 + 148 + // The hook runs asynchronously after the push response; wait for it to 149 + // finish (it ends with an error because of the /src write attempt). 150 + waitForLog(t, &logBuf, "hook: finished", 30*time.Second) 151 + 152 + logs := logBuf.String() 153 + if !strings.Contains(logs, "hook: running") { 154 + t.Fatalf("hook did not run; logs:\n%s", logs) 155 + } 156 + // /src is readable and /tmp is writable. 157 + for _, want := range []string{"hello from repo", "built"} { 158 + if !strings.Contains(logs, want) { 159 + t.Errorf("hook output missing %q; logs:\n%s", want, logs) 160 + } 161 + } 162 + // Writing to /src is rejected and aborts the script. 163 + if !strings.Contains(logs, "read-only filesystem") { 164 + t.Errorf("expected read-only error when writing /src; logs:\n%s", logs) 165 + } 166 + if strings.Contains(logs, "WROTE_SRC") { 167 + t.Errorf("hook was able to write to read-only /src; logs:\n%s", logs) 168 + } 169 + } 170 + 171 + // TestReceivePackHookAbsent confirms a push with no hook file is a no-op (push 172 + // still succeeds, nothing logged as a hook run). 173 + func TestReceivePackHookAbsent(t *testing.T) { 174 + if _, err := exec.LookPath("git"); err != nil { 175 + t.Skip("git not installed") 176 + } 177 + 178 + var logBuf syncBuffer 179 + prev := slog.Default() 180 + slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) 181 + defer slog.SetDefault(prev) 182 + 183 + fs := memfs.New() 184 + d := &daemon{ 185 + fs: fs, 186 + loader: transport.NewFilesystemLoader(fs, false), 187 + allowPush: true, 188 + allowHooks: true, 189 + hookTimeout: 30 * time.Second, 190 + } 191 + 192 + ctx, cancel := context.WithCancel(context.Background()) 193 + defer cancel() 194 + 195 + ln, err := net.Listen("tcp", "127.0.0.1:0") 196 + if err != nil { 197 + t.Fatalf("listen: %v", err) 198 + } 199 + go func() { _ = d.Serve(ctx, ln) }() 200 + 201 + remote := "git://" + ln.Addr().String() + "/plain.git" 202 + work := t.TempDir() 203 + runGit(t, work, "init", "-b", "main") 204 + runGit(t, work, "config", "user.email", "test@example.com") 205 + runGit(t, work, "config", "user.name", "Test") 206 + runGit(t, work, "commit", "--allow-empty", "-m", "no hook") 207 + runGit(t, work, "push", remote, "main") 208 + 209 + // runHook logs this at debug level once it sees there is no hook file. 210 + waitForLog(t, &logBuf, "no hook file", 10*time.Second) 211 + 212 + if strings.Contains(logBuf.String(), "hook: running") { 213 + t.Errorf("hook ran for a repo with no hook file; logs:\n%s", logBuf.String()) 214 + } 215 + } 216 + 217 + func writeFile(t *testing.T, path, content string) { 218 + t.Helper() 219 + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 220 + t.Fatalf("mkdir %q: %v", path, err) 221 + } 222 + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { 223 + t.Fatalf("write %q: %v", path, err) 224 + } 225 + } 226 + 227 + // waitForLog blocks until the captured log output contains substr, or fails the 228 + // test after timeout. Hooks run asynchronously, so tests synchronize on a 229 + // terminal log line rather than the daemon's WaitGroup (which the push response 230 + // can outrun, racing Add against Wait). 231 + func waitForLog(t *testing.T, buf *syncBuffer, substr string, timeout time.Duration) { 232 + t.Helper() 233 + deadline := time.Now().Add(timeout) 234 + for time.Now().Before(deadline) { 235 + if strings.Contains(buf.String(), substr) { 236 + return 237 + } 238 + time.Sleep(10 * time.Millisecond) 239 + } 240 + t.Fatalf("timed out waiting for log %q; logs:\n%s", substr, buf.String()) 241 + }
+1 -1
cmd/objgitd/http.go
··· 125 125 GitProtocol: gitProtocol, 126 126 }) 127 127 case transport.ReceivePackService: 128 - err = transport.ReceivePack(r.Context(), st, in, out, &transport.ReceivePackRequest{ 128 + err = d.receivePack(r.Context(), st, st, repoPath, in, out, &transport.ReceivePackRequest{ 129 129 StatelessRPC: true, 130 130 GitProtocol: gitProtocol, 131 131 })
+21 -4
cmd/objgitd/main.go
··· 29 29 bucket = flag.String("bucket", "", "Tigris bucket that holds the git repositories") 30 30 allowPush = flag.Bool("allow-push", false, "allow unauthenticated git-receive-pack (push) requests") 31 31 slogLevel = flag.String("slog-level", "INFO", "log level (DEBUG, INFO, WARN, ERROR)") 32 + 33 + allowHooks = flag.Bool("allow-hooks", false, "run .objgit/hooks/receive-pack in a sandbox after a successful push") 34 + hookTimeout = flag.Duration("hook-timeout", 60*time.Second, "wall-clock limit for a single hook run") 32 35 ) 33 36 34 37 func main() { ··· 68 71 } 69 72 70 73 d := &daemon{ 71 - fs: fsys, 72 - loader: transport.NewFilesystemLoader(fsys, false), 73 - allowPush: *allowPush, 74 + fs: fsys, 75 + loader: transport.NewFilesystemLoader(fsys, false), 76 + allowPush: *allowPush, 77 + allowHooks: *allowHooks, 78 + hookTimeout: *hookTimeout, 74 79 } 75 80 76 81 slog.Info("objgitd listening", ··· 78 83 "http_bind", *httpBind, 79 84 "bucket", *bucket, 80 85 "allow_push", *allowPush, 86 + "allow_hooks", *allowHooks, 81 87 ) 82 88 83 89 g, gCtx := errgroup.WithContext(ctx) ··· 112 118 }) 113 119 } 114 120 115 - if err := g.Wait(); err != nil { 121 + err = g.Wait() 122 + 123 + // Let in-flight async hooks finish before exiting, but don't hang forever. 124 + drained := make(chan struct{}) 125 + go func() { d.hookWG.Wait(); close(drained) }() 126 + select { 127 + case <-drained: 128 + case <-time.After(10 * time.Second): 129 + slog.Warn("shutdown: gave up waiting for in-flight hooks") 130 + } 131 + 132 + if err != nil { 116 133 slog.Error("server stopped", "err", err) 117 134 os.Exit(1) 118 135 }
+234
docs/plans/git-hooks-kefka.md
··· 1 + # Plan: post-receive hooks for objgitd (sandboxed via kefka) 2 + 3 + ## Context 4 + 5 + `objgitd` serves bare git repos stored as objects in S3 (via `internal/s3fs`, 6 + exposed as a `billy.Filesystem`). We want to run a user-supplied shell script 7 + after a successful push, with a checkout of the repo visible at `/src`, executed 8 + in a safe sandbox. 9 + 10 + The execution primitive is `tangled.org/xeiaso.net/kefka` — but kefka is **not an 11 + OS sandbox**. It is a virtual `bash` interpreter (`mvdan.cc/sh/v3`) wired to a 12 + `billy.Filesystem` plus a fixed registry of built-in commands (~50 coreutils). 13 + Safety comes from two facts: a script can only touch the `billy.Filesystem` we 14 + hand it, and it can only run commands we register. There is no arbitrary binary 15 + execution, no network, no host access. This is a clean fit because objgit already 16 + models repos as `billy.Filesystem`, so the `/src` checkout never touches host disk. 17 + 18 + ### Decisions (confirmed with user) 19 + 20 + - **Trigger:** push only (`receive-pack`). Hook file: `.objgit/hooks/receive-pack`. 21 + - **Timing:** post-receive, **async** — refs are already updated, the client gets 22 + its response immediately, the hook runs in the background and **cannot reject** 23 + the push. 24 + - **Command set:** coreutils only (no WASM `python3`/`jq`/`qjs`/`rg`). 25 + - **Output:** slog only (key `"err"`), never relayed to the pusher. 26 + - **`/src`:** a **lazy read-only `TreeFS`** (`billy.Filesystem` view over the 27 + commit tree) — no eager copy, scales to large repos. 28 + - **`/tmp`:** a writable in-memory `memfs` for scratch, so scripts can write 29 + temp files and use redirections. Mounted alongside `/src` via a composite fs. 30 + - **billysh:** manually vendored into objgit (kefka's `internal/billysh` is not 31 + importable). User approved. 32 + 33 + ### Known limitations (document in code + docs/plan) 34 + 35 + - `/src` is read-only; only `/tmp` is writable. Writes/redirections outside 36 + `/tmp` (e.g. into `/src`) fail. Scripts should use `/tmp` (and `$TMPDIR`) for 37 + scratch. A copy-on-write `/src` is a future enhancement if ever needed. 38 + - Hooks are advisory/async: failures, parse errors, and timeouts are logged only. 39 + - git:// transport is unauthenticated; anyone who can push can run a (sandboxed) 40 + hook. Acceptable given kefka's confinement, but note it. 41 + 42 + ## Components 43 + 44 + ### 1. `internal/kefkash` — vendored shell wiring (new package) 45 + 46 + Copy the four handler constructors + `readOnlyFile` shim from kefka's 47 + `internal/billysh/billysh.go` (~81 lines, depends only on public symbols: 48 + `registry.Impl` and its exported `Resolve`/`Chdir`/`Pwd`, `billy.Filesystem`, 49 + `mvdan.cc/sh/v3/interp`). Add a header comment crediting the kefka source 50 + (mirror the `internal/s3fs` "vendored from" convention). Exports: 51 + 52 + - `CallHandler(reg, fsys, stdout, stderr) interp.CallHandlerFunc` 53 + - `FsysStatHandler(reg, fsys) interp.StatHandlerFunc` 54 + - `FsysOpenHandler(reg, fsys) interp.OpenHandlerFunc` 55 + - `FsysReadDirHandler(reg, fsys) interp.ReadDirHandlerFunc2` 56 + 57 + ### 2. `internal/treefs` — lazy read-only git-tree filesystem (new package) 58 + 59 + A `billy.Filesystem` backed by `(*object.Tree, storer.EncodedObjectStorer)`. 60 + Reads resolve paths on demand via `tree.File(path)` (blob) / `tree.Tree(path)` 61 + (subdir); blob contents stream from `(*object.Blob).Reader()`. All write methods 62 + return a sentinel read-only error (`billy`'s `ErrReadOnly` or a local one). 63 + 64 + Implements the full `billy.Filesystem` interface: 65 + 66 + - Read: `Open`, `OpenFile` (read flags only; reject `O_CREATE`/`O_WRONLY`), 67 + `Stat`, `Lstat`, `ReadDir`, `Join`, `Root`. 68 + - `Chroot(path)`: resolve the subtree and return a `TreeFS` rooted there (cheap, 69 + no copy); or return read-only error if path missing. 70 + - Write/mutate (`Create`, `Rename`, `Remove`, `MkdirAll`, `Symlink`, `TempFile`): 71 + return read-only error. 72 + - File handle: a `billy.File` wrapping an `io.ReadCloser` + `bytes`/seek over the 73 + blob; `Write`/`Truncate` return read-only error. Map `filemode.Executable`/ 74 + `Regular`/`Symlink` to `os.FileMode` for `Stat`. 75 + 76 + Verified go-git v6 APIs (`go-git/v6 v6.0.0-alpha.4`): 77 + `object.GetCommit(st, hash) (*Commit, error)`, `(*Commit).Tree()`, 78 + `(*Tree).File(path)`, `(*Tree).Tree(path)`, `(*Tree).Files() *FileIter`, 79 + `tree.Entries`, `(*Blob).Reader()`, `filemode` constants. 80 + 81 + ### 2b. `internal/mountfs` — path-prefix composite filesystem (new package) 82 + 83 + A `billy.Filesystem` that dispatches by leading path component to one of several 84 + mounted filesystems, so the kefka sandbox sees both `/src` and `/tmp`: 85 + 86 + - `/src/...` → the read-only `TreeFS` (§2). 87 + - `/tmp/...` → a writable `memfs.New()`. 88 + - The root listing (`ReadDir("/")`) reports `src` and `tmp` as dirs; other paths 89 + return not-exist. 90 + 91 + Implementation: a small struct holding `map[string]billy.Filesystem` keyed by 92 + top-level mount name. Each method strips the mount prefix, delegates to the 93 + matching fs (translating the path), and re-prefixes results (e.g. `ReadDir`, 94 + `Stat` names, `Join`). Write methods on `/tmp` succeed (memfs); on `/src` they 95 + return the TreeFS read-only error. `Chroot` into a mount delegates to that fs. 96 + This keeps `TreeFS` and `memfs` simple and isolates the routing concern. 97 + 98 + ### 3. `cmd/objgitd/hooks.go` — orchestration (new file) 99 + 100 + - `type refUpdate struct { Name plumbing.ReferenceName; Old, New plumbing.Hash }` 101 + - `snapshotRefs(st storage.Storer) (map[plumbing.ReferenceName]plumbing.Hash, error)` 102 + — `st.IterReferences()` (Storer embeds `ReferenceStorer`), keep 103 + `HashReference && Name().IsBranch()`. 104 + - `diffRefs(before, after) []refUpdate` — created (Old=zero), updated (hash 105 + differs), deleted (New=zero). 106 + - `(d *daemon) runHooks(repoPath, service string, st storage.Storer, updates []refUpdate)`: 107 + for each update where `!New.IsZero()` (skip deletions): 108 + 1. `c := object.GetCommit(st, u.New)`; `tree := c.Tree()`. 109 + 2. `hookFile, err := tree.File(".objgit/hooks/receive-pack")`; if not found → 110 + debug log, continue (no-op). 111 + 3. Read hook script bytes from `hookFile`. 112 + 4. Build the sandbox fs: `mountfs{ "/src": treefs.New(tree, st), "/tmp": 113 + memfs.New() }` and mount it as the kefka root. cwd = `/src` via 114 + `interp.Dir("/src")` + `reg.Chdir(fsys, "/src")`. 115 + 5. Construct kefka runner (see §4), per-hook `ctx` = 116 + `context.WithTimeout(context.Background(), d.hookTimeout)` (independent of the 117 + request ctx so it isn't cancelled when the response finishes). 118 + 6. Parse with `syntax.NewParser(syntax.Variant(syntax.LangBash))`, `sh.Run`. 119 + 7. Capture stdout/stderr to buffers; log via slog (`"err"` key, exit code from 120 + `interp.ExitStatus`). Feed git-style `old new ref\n` on stdin for 121 + compatibility, and inject env vars. 122 + 123 + Env vars: `OBJGIT_REPO`, `OBJGIT_SERVICE=receive-pack`, `OBJGIT_REF` 124 + (`refs/heads/...`), `OBJGIT_BRANCH` (short), `OBJGIT_OLD_SHA`, `OBJGIT_NEW_SHA`, 125 + plus kefka base env (`HOME=/tmp`, `PWD=/src`, `TMPDIR=/tmp`, 126 + `PATH=/usr/bin:/bin`, `IFS`). 127 + 128 + ### 4. kefka runner construction (in `hooks.go`) 129 + 130 + ```go 131 + reg := registry.New() 132 + coreutils.Register(reg) 133 + _ = reg.Chdir(fsys, "/src") 134 + var sh *interp.Runner 135 + mw := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 136 + return func(ctx context.Context, args []string) error { return reg.Exec(ctx, fsys, sh, args) } 137 + } 138 + sh, err = interp.New( 139 + interp.Env(expand.ListEnviron(envPairs...)), 140 + interp.StdIO(stdin, &outBuf, &errBuf), 141 + interp.ExecHandlers(mw), 142 + interp.CallHandler(kefkash.CallHandler(reg, fsys, &outBuf, &errBuf)), 143 + interp.StatHandler(kefkash.FsysStatHandler(reg, fsys)), 144 + interp.OpenHandler(kefkash.FsysOpenHandler(reg, fsys)), 145 + interp.ReadDirHandler2(kefkash.FsysReadDirHandler(reg, fsys)), 146 + interp.Dir("/src"), 147 + ) 148 + ``` 149 + 150 + ### 5. Integration into the two receive-pack call sites 151 + 152 + Add a `daemon` method that wraps `transport.ReceivePack`, and route the **two 153 + real receive call sites** through it — NOT the advertisement phases: 154 + 155 + - `cmd/objgitd/git_protocol.go` ~L138 (git://, currently 156 + `transport.ReceivePack(ctx, streamingStorer{Storer: st}, r, conn, req)`). 157 + - `cmd/objgitd/http.go` ~L128 (`handleRPC`). **Do not** touch `handleInfoRefs` 158 + (AdvertiseRefs) or the git:// advertise path. 159 + 160 + ```go 161 + func (d *daemon) receivePack(ctx context.Context, rpStorer, readStorer storage.Storer, 162 + repoPath string, r io.ReadCloser, w io.Writer, req *transport.ReceivePackRequest) error { 163 + var before map[plumbing.ReferenceName]plumbing.Hash 164 + if d.allowHooks { before, _ = snapshotRefs(readStorer) } 165 + err := transport.ReceivePack(ctx, rpStorer, r, w, req) 166 + if err != nil || !d.allowHooks { return err } 167 + after, serr := snapshotRefs(readStorer) 168 + if serr != nil { slog.Error("hook: snapshot after push failed", "err", serr); return nil } 169 + if updates := diffRefs(before, after); len(updates) > 0 { 170 + d.hookWG.Add(1) 171 + go func() { defer d.hookWG.Done(); d.runHooks(repoPath, "receive-pack", readStorer, updates) }() 172 + } 173 + return nil 174 + } 175 + ``` 176 + 177 + - git:// call site passes `rpStorer = streamingStorer{Storer: st}` (preserves the 178 + deadlock fix) and `readStorer = st`. 179 + - HTTP call site passes `rpStorer = st`, `readStorer = st`. 180 + 181 + ### 6. `daemon` struct + flags 182 + 183 + `cmd/objgitd/git_protocol.go` — add fields: 184 + 185 + ```go 186 + allowHooks bool 187 + hookTimeout time.Duration 188 + hookWG sync.WaitGroup 189 + ``` 190 + 191 + `cmd/objgitd/main.go` — new flags (kebab-case + flagenv, mirroring `-allow-push`): 192 + 193 + - `-allow-hooks` (bool, default false) → `ALLOW_HOOKS` 194 + - `-hook-timeout` (duration, default `60s`) → `HOOK_TIMEOUT` 195 + 196 + Wire into the `daemon`. In the shutdown path (after `g.Wait()` / in the shutdown 197 + goroutine), drain in-flight hooks: `select` on `d.hookWG` done vs a bounded 198 + deadline so SIGTERM lets running hooks finish rather than killing them. 199 + 200 + ## Files 201 + 202 + - `internal/kefkash/kefkash.go` — new (vendored billysh wiring). 203 + - `internal/treefs/treefs.go` (+ `file.go`) — new (lazy read-only tree FS). 204 + - `internal/mountfs/mountfs.go` — new (path-prefix composite fs: `/src` + `/tmp`). 205 + - `cmd/objgitd/hooks.go` — new (snapshot/diff/runHooks + runner). 206 + - `cmd/objgitd/git_protocol.go` — daemon fields; route git:// receive through `receivePack`. 207 + - `cmd/objgitd/http.go` — route `handleRPC` receive through `receivePack`. 208 + - `cmd/objgitd/main.go` — flags + daemon wiring + shutdown drain. 209 + - `go.mod` — promote `tangled.org/xeiaso.net/kefka` to a direct require; `go mod 210 + tidy` pulls `mvdan.cc/sh/v3` (network needed on first build). 211 + - `docs/plans/git-hooks.md` — short design doc per repo convention. 212 + 213 + ## Verification 214 + 215 + 1. `go build ./...` and `go vet ./...`. 216 + 2. Unit tests: 217 + - `internal/treefs`: build an in-memory repo (go-git memfs storer), commit a 218 + tree with nested dirs + an executable file, assert `Open`/`ReadDir`/`Stat` 219 + return correct content/modes and writes return read-only errors. 220 + - `cmd/objgitd`: `diffRefs`/`snapshotRefs` table tests (created/updated/deleted). 221 + 3. End-to-end (gated by `exec.LookPath("git")`, reuse `seedRepo`/`runGit` helpers 222 + from `git_protocol_test.go`): 223 + - Start a `daemon` with `allowHooks=true` over an in-memory/test S3FS. 224 + - Push a repo containing `.objgit/hooks/receive-pack` that runs a coreutils 225 + command reading `/src` and writing scratch to `/tmp` (e.g. 226 + `cat /src/README.md && echo built > /tmp/out && cat /tmp/out`); assert the 227 + `/tmp` write succeeds and a write into `/src` fails. 228 + - Assert the push succeeds immediately, then (synchronize on `hookWG`) assert 229 + the hook ran by capturing slog output (inject a `*slog.Logger` writing to a 230 + buffer) and checking the logged stdout/exit code. 231 + - Negative: push without a hook file → no-op; push a hook that exits non-zero 232 + → push still succeeds, error logged. 233 + 4. Manual: run `./objgitd -bucket $BUCKET -allow-push -allow-hooks`, push a repo 234 + with a hook, confirm structured logs show hook stdout and exit status.
+14 -9
go.mod
··· 12 12 github.com/joho/godotenv v1.5.1 13 13 github.com/tigrisdata/storage-go v0.6.0 14 14 go.uber.org/atomic v1.11.0 15 + golang.org/x/sync v0.20.0 16 + mvdan.cc/sh/v3 v3.13.1 17 + tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8 15 18 ) 16 19 17 20 require ( 18 21 github.com/Microsoft/go-winio v0.6.2 // indirect 19 22 github.com/ProtonMail/go-crypto v1.4.1 // indirect 20 23 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect 21 - github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect 22 - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect 23 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect 24 + github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect 25 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect 26 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect 24 27 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect 25 28 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect 26 - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 27 29 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect 28 30 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect 29 31 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.16 // indirect 30 32 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect 31 33 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect 32 - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect 33 - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect 34 - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect 35 - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 34 + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect 35 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect 36 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect 37 + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect 36 38 github.com/cloudflare/circl v1.6.3 // indirect 37 39 github.com/emirpasic/gods v1.18.1 // indirect 38 40 github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect ··· 41 43 github.com/go-git/gcfg/v2 v2.0.2 // indirect 42 44 github.com/kevinburke/ssh_config v1.6.0 // indirect 43 45 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 46 + github.com/pborman/getopt/v2 v2.1.0 // indirect 44 47 github.com/pjbgf/sha1cd v0.6.0 // indirect 48 + github.com/pmezard/go-difflib v1.0.0 // indirect 45 49 github.com/sergi/go-diff v1.4.0 // indirect 46 50 golang.org/x/crypto v0.51.0 // indirect 47 51 golang.org/x/net v0.54.0 // indirect 48 - golang.org/x/sync v0.20.0 // indirect 49 52 golang.org/x/sys v0.44.0 // indirect 53 + golang.org/x/term v0.43.0 // indirect 54 + golang.org/x/text v0.37.0 // indirect 50 55 )
+32 -16
go.sum
··· 10 10 github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= 11 11 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= 12 12 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= 13 - github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= 14 - github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= 15 - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= 16 - github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= 17 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= 18 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= 13 + github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= 14 + github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= 15 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= 16 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= 17 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= 18 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= 19 19 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= 20 20 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= 21 21 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= 22 22 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= 23 - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= 24 - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= 25 23 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= 26 24 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= 27 25 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= ··· 34 32 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= 35 33 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0 h1:gfPQ6do5PZTCc5n/vZUHz/G8McrNrfERGSO+iHvVbCA= 36 34 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0/go.mod h1:wO6U9egJtCtsZEHG2AAcFf1kUWDRrH0Iif6K3bVmmdE= 37 - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= 38 - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= 39 - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= 40 - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= 41 - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= 42 - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= 43 - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= 44 - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= 35 + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= 36 + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= 37 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= 38 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= 39 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= 40 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= 41 + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= 42 + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= 45 43 github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= 46 44 github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 47 45 github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= 48 46 github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 47 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 48 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 49 49 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 50 50 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 51 51 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 69 69 github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= 70 70 github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= 71 71 github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= 72 + github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= 73 + github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 74 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 75 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 72 76 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 73 77 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 74 78 github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= ··· 76 80 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 77 81 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 78 82 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 83 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 84 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 79 85 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 80 86 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 87 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 88 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 89 + github.com/pborman/getopt/v2 v2.1.0 h1:eNfR+r+dWLdWmV8g5OlpyrTYHkhVNxHBdN2cCrJmOEA= 90 + github.com/pborman/getopt/v2 v2.1.0/go.mod h1:4NtW75ny4eBw9fO1bhtNdYTlZKYX5/tBLtsOpwKIKd0= 81 91 github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= 82 92 github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 83 93 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 84 94 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 95 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 96 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 85 97 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 86 98 github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 87 99 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= ··· 110 122 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 111 123 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 112 124 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 125 + mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= 126 + mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= 127 + tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8 h1:R008zBlWio3SXRZUaEDDDzzEf82X6e6KLvqhNddXprI= 128 + tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8/go.mod h1:p474Le4nfwuwnuAWowRhXMsTYfkpOMscchuaFvDa2zM=
+101
internal/kefkash/kefkash.go
··· 1 + // Package kefkash wires a billy.Filesystem into an mvdan.cc/sh interpreter the 2 + // way the kefka virtual shell does. The handler constructors are vendored from 3 + // kefka's internal/billysh (tangled.org/xeiaso.net/kefka), which is not 4 + // importable because it lives under internal/. They depend only on kefka's 5 + // public command/registry package. 6 + // 7 + // One deliberate deviation from upstream: FsysOpenHandler permits write opens 8 + // and delegates them to the filesystem's OpenFile. objgit hands the sandbox a 9 + // composite filesystem (see internal/mountfs) where /src is read-only and /tmp 10 + // is writable, so the filesystem itself — not this handler — enforces what may 11 + // be written. Upstream billysh rejects all write opens because it mounts a 12 + // single read-only tree. 13 + package kefkash 14 + 15 + import ( 16 + "context" 17 + "fmt" 18 + "io" 19 + "io/fs" 20 + "os" 21 + 22 + "github.com/go-git/go-billy/v6" 23 + "mvdan.cc/sh/v3/interp" 24 + "tangled.org/xeiaso.net/kefka/command/registry" 25 + ) 26 + 27 + // FsysStatHandler resolves stat calls against fsys, honouring followSymlinks 28 + // when the filesystem supports Lstat. 29 + func FsysStatHandler(reg *registry.Impl, fsys billy.Filesystem) interp.StatHandlerFunc { 30 + return func(ctx context.Context, name string, followSymlinks bool) (fs.FileInfo, error) { 31 + resolved := reg.Resolve(name) 32 + if !followSymlinks { 33 + if r, ok := fsys.(billy.Symlink); ok { 34 + return r.Lstat(resolved) 35 + } 36 + } 37 + return fsys.Stat(resolved) 38 + } 39 + } 40 + 41 + // FsysOpenHandler opens files against fsys for both reading and writing. Read 42 + // opens are wrapped so writes through the returned handle are rejected; write 43 + // opens are delegated to fsys.OpenFile, leaving the filesystem to allow or deny 44 + // the write (e.g. a read-only /src vs a writable /tmp). 45 + func FsysOpenHandler(reg *registry.Impl, fsys billy.Filesystem) interp.OpenHandlerFunc { 46 + return func(ctx context.Context, name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { 47 + resolved := reg.Resolve(name) 48 + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { 49 + return fsys.OpenFile(resolved, flag, perm) 50 + } 51 + f, err := fsys.Open(resolved) 52 + if err != nil { 53 + return nil, err 54 + } 55 + return readOnlyFile{f}, nil 56 + } 57 + } 58 + 59 + // FsysReadDirHandler lists directories against fsys. 60 + func FsysReadDirHandler(reg *registry.Impl, fsys billy.Filesystem) interp.ReadDirHandlerFunc2 { 61 + return func(ctx context.Context, name string) ([]fs.DirEntry, error) { 62 + return fsys.ReadDir(reg.Resolve(name)) 63 + } 64 + } 65 + 66 + type readOnlyFile struct{ billy.File } 67 + 68 + func (readOnlyFile) Write([]byte) (int, error) { return 0, fs.ErrPermission } 69 + 70 + // CallHandler intercepts cd and pwd before interp's builtins handle them, so 71 + // directory state is routed through the registry's fsys-relative pwd instead of 72 + // interp's host-rooted Dir. Intercepted calls are replaced with `:` (no-op) so 73 + // interp's builtin doesn't run. 74 + func CallHandler(reg *registry.Impl, fsys billy.Filesystem, stdout, stderr io.Writer) interp.CallHandlerFunc { 75 + return func(ctx context.Context, args []string) ([]string, error) { 76 + if len(args) == 0 { 77 + return args, nil 78 + } 79 + switch args[0] { 80 + case "cd": 81 + target := "" 82 + if len(args) > 1 { 83 + target = args[1] 84 + } 85 + if err := reg.Chdir(fsys, target); err != nil { 86 + fmt.Fprintln(stderr, err) 87 + return []string{"false"}, nil 88 + } 89 + return []string{":"}, nil 90 + case "pwd": 91 + pwd := reg.Pwd() 92 + if pwd == "." { 93 + fmt.Fprintln(stdout, "/") 94 + } else { 95 + fmt.Fprintln(stdout, "/"+pwd) 96 + } 97 + return []string{":"}, nil 98 + } 99 + return args, nil 100 + } 101 + }
+237
internal/mountfs/mountfs.go
··· 1 + // Package mountfs composes several billy filesystems into one, dispatching by 2 + // the first path component. objgit uses it to give a hook sandbox a read-only 3 + // /src (a git tree) alongside a writable /tmp (an in-memory scratch fs) under a 4 + // single filesystem, since the kefka shell mounts exactly one. 5 + // 6 + // The root directory is virtual: it only lists the configured mount points and 7 + // cannot itself be written to. 8 + package mountfs 9 + 10 + import ( 11 + "io/fs" 12 + "os" 13 + "path" 14 + "sort" 15 + "strings" 16 + "time" 17 + 18 + "github.com/go-git/go-billy/v6" 19 + ) 20 + 21 + // FS routes operations to a mounted filesystem chosen by the leading path 22 + // component. 23 + type FS struct { 24 + mounts map[string]billy.Filesystem 25 + names []string // sorted mount names, for a stable root listing 26 + } 27 + 28 + // New builds a composite filesystem. Keys are top-level directory names (e.g. 29 + // "src", "tmp") without slashes. 30 + func New(mounts map[string]billy.Filesystem) *FS { 31 + names := make([]string, 0, len(mounts)) 32 + for name := range mounts { 33 + names = append(names, name) 34 + } 35 + sort.Strings(names) 36 + return &FS{mounts: mounts, names: names} 37 + } 38 + 39 + // route resolves p to a mount and a path relative to it. isRoot is true when p 40 + // addresses the virtual root itself. isMount is true when p addresses a mount 41 + // point exactly (rel == "."). 42 + func (f *FS) route(p string) (sub billy.Filesystem, rel string, isRoot, isMount bool, err error) { 43 + clean := path.Clean("/" + p)[1:] // strip leading slash; root -> "" 44 + if clean == "" { 45 + return nil, "", true, false, nil 46 + } 47 + name, rest, _ := strings.Cut(clean, "/") 48 + sub, ok := f.mounts[name] 49 + if !ok { 50 + return nil, "", false, false, fs.ErrNotExist 51 + } 52 + if rest == "" { 53 + return sub, ".", false, true, nil 54 + } 55 + return sub, rest, false, false, nil 56 + } 57 + 58 + func pathErr(op, name string, err error) error { 59 + return &os.PathError{Op: op, Path: name, Err: err} 60 + } 61 + 62 + func (f *FS) Open(filename string) (billy.File, error) { 63 + return f.OpenFile(filename, os.O_RDONLY, 0) 64 + } 65 + 66 + func (f *FS) OpenFile(filename string, flag int, perm fs.FileMode) (billy.File, error) { 67 + sub, rel, isRoot, isMount, err := f.route(filename) 68 + if err != nil { 69 + return nil, pathErr("open", filename, err) 70 + } 71 + if isRoot || isMount { 72 + return nil, pathErr("open", filename, billy.ErrNotSupported) 73 + } 74 + return sub.OpenFile(rel, flag, perm) 75 + } 76 + 77 + func (f *FS) Stat(filename string) (fs.FileInfo, error) { 78 + sub, rel, isRoot, isMount, err := f.route(filename) 79 + if err != nil { 80 + return nil, pathErr("stat", filename, err) 81 + } 82 + if isRoot { 83 + return dirInfo{name: "/"}, nil 84 + } 85 + if isMount { 86 + return dirInfo{name: path.Base(path.Clean("/" + filename))}, nil 87 + } 88 + return sub.Stat(rel) 89 + } 90 + 91 + func (f *FS) Lstat(filename string) (fs.FileInfo, error) { 92 + sub, rel, isRoot, isMount, err := f.route(filename) 93 + if err != nil { 94 + return nil, pathErr("lstat", filename, err) 95 + } 96 + if isRoot || isMount { 97 + return f.Stat(filename) 98 + } 99 + if sym, ok := sub.(billy.Symlink); ok { 100 + return sym.Lstat(rel) 101 + } 102 + return sub.Stat(rel) 103 + } 104 + 105 + func (f *FS) ReadDir(p string) ([]fs.DirEntry, error) { 106 + sub, rel, isRoot, _, err := f.route(p) 107 + if err != nil { 108 + return nil, pathErr("readdir", p, err) 109 + } 110 + if isRoot { 111 + out := make([]fs.DirEntry, 0, len(f.names)) 112 + for _, name := range f.names { 113 + out = append(out, dirInfo{name: name}) 114 + } 115 + return out, nil 116 + } 117 + return sub.ReadDir(rel) 118 + } 119 + 120 + func (f *FS) Readlink(link string) (string, error) { 121 + sub, rel, isRoot, isMount, err := f.route(link) 122 + if err != nil { 123 + return "", pathErr("readlink", link, err) 124 + } 125 + if isRoot || isMount { 126 + return "", pathErr("readlink", link, billy.ErrNotSupported) 127 + } 128 + if sym, ok := sub.(billy.Symlink); ok { 129 + return sym.Readlink(rel) 130 + } 131 + return "", pathErr("readlink", link, billy.ErrNotSupported) 132 + } 133 + 134 + func (f *FS) Symlink(target, link string) error { 135 + sub, rel, isRoot, isMount, err := f.route(link) 136 + if err != nil { 137 + return pathErr("symlink", link, err) 138 + } 139 + if isRoot || isMount { 140 + return pathErr("symlink", link, billy.ErrReadOnly) 141 + } 142 + if sym, ok := sub.(billy.Symlink); ok { 143 + return sym.Symlink(target, rel) 144 + } 145 + return pathErr("symlink", link, billy.ErrNotSupported) 146 + } 147 + 148 + func (f *FS) Create(filename string) (billy.File, error) { 149 + return f.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o666) 150 + } 151 + 152 + func (f *FS) Remove(filename string) error { 153 + sub, rel, isRoot, isMount, err := f.route(filename) 154 + if err != nil { 155 + return pathErr("remove", filename, err) 156 + } 157 + if isRoot || isMount { 158 + return pathErr("remove", filename, billy.ErrReadOnly) 159 + } 160 + return sub.Remove(rel) 161 + } 162 + 163 + func (f *FS) MkdirAll(filename string, perm fs.FileMode) error { 164 + sub, rel, isRoot, isMount, err := f.route(filename) 165 + if err != nil { 166 + return pathErr("mkdir", filename, err) 167 + } 168 + if isRoot || isMount { 169 + return pathErr("mkdir", filename, billy.ErrReadOnly) 170 + } 171 + return sub.MkdirAll(rel, perm) 172 + } 173 + 174 + // Rename only works within a single mount. 175 + func (f *FS) Rename(oldpath, newpath string) error { 176 + oldSub, oldRel, oldRoot, oldMount, err := f.route(oldpath) 177 + if err != nil { 178 + return pathErr("rename", oldpath, err) 179 + } 180 + newSub, newRel, newRoot, newMount, err := f.route(newpath) 181 + if err != nil { 182 + return pathErr("rename", newpath, err) 183 + } 184 + if oldRoot || oldMount || newRoot || newMount || oldSub != newSub { 185 + return pathErr("rename", oldpath, billy.ErrNotSupported) 186 + } 187 + return oldSub.Rename(oldRel, newRel) 188 + } 189 + 190 + func (f *FS) TempFile(dir, prefix string) (billy.File, error) { 191 + sub, rel, isRoot, _, err := f.route(dir) 192 + if err != nil { 193 + return nil, pathErr("tempfile", dir, err) 194 + } 195 + if isRoot { 196 + return nil, pathErr("tempfile", dir, billy.ErrReadOnly) 197 + } 198 + return sub.TempFile(rel, prefix) 199 + } 200 + 201 + // Chroot returns the mounted filesystem (optionally further chrooted) so callers 202 + // that chroot into /src or /tmp keep working. 203 + func (f *FS) Chroot(p string) (billy.Filesystem, error) { 204 + sub, rel, isRoot, isMount, err := f.route(p) 205 + if err != nil { 206 + return nil, pathErr("chroot", p, err) 207 + } 208 + if isRoot { 209 + return f, nil 210 + } 211 + if isMount { 212 + return sub, nil 213 + } 214 + return sub.Chroot(rel) 215 + } 216 + 217 + func (f *FS) Root() string { return "/" } 218 + 219 + func (f *FS) Join(elem ...string) string { return path.Join(elem...) } 220 + 221 + // dirInfo describes the virtual root and the mount-point directories. 222 + type dirInfo struct{ name string } 223 + 224 + func (d dirInfo) Name() string { return d.name } 225 + func (d dirInfo) Size() int64 { return 0 } 226 + func (d dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 } 227 + func (d dirInfo) ModTime() time.Time { return time.Time{} } 228 + func (d dirInfo) IsDir() bool { return true } 229 + func (d dirInfo) Sys() any { return nil } 230 + func (d dirInfo) Type() fs.FileMode { return fs.ModeDir } 231 + func (d dirInfo) Info() (fs.FileInfo, error) { return d, nil } 232 + 233 + var ( 234 + _ billy.Filesystem = (*FS)(nil) 235 + _ fs.FileInfo = dirInfo{} 236 + _ fs.DirEntry = dirInfo{} 237 + )
+30
internal/treefs/file.go
··· 1 + package treefs 2 + 3 + import ( 4 + "bytes" 5 + "io/fs" 6 + 7 + "github.com/go-git/go-billy/v6" 8 + ) 9 + 10 + // file is a read-only billy.File backed by an in-memory copy of a blob. 11 + type file struct { 12 + *bytes.Reader 13 + name string 14 + info fileInfo 15 + } 16 + 17 + func newFile(name string, data []byte, info fileInfo) *file { 18 + return &file{Reader: bytes.NewReader(data), name: name, info: info} 19 + } 20 + 21 + func (f *file) Name() string { return f.name } 22 + func (f *file) Stat() (fs.FileInfo, error) { return f.info, nil } 23 + func (f *file) Close() error { return nil } 24 + func (f *file) Write([]byte) (int, error) { return 0, billy.ErrReadOnly } 25 + func (f *file) WriteAt([]byte, int64) (int, error) { return 0, billy.ErrReadOnly } 26 + func (f *file) Truncate(int64) error { return billy.ErrReadOnly } 27 + func (f *file) Lock() error { return nil } 28 + func (f *file) Unlock() error { return nil } 29 + 30 + var _ billy.File = (*file)(nil)
+187
internal/treefs/treefs.go
··· 1 + // Package treefs exposes a git tree (a commit's contents at a single ref) as a 2 + // read-only billy.Filesystem. Nothing is copied up front: directory listings 3 + // read the in-memory tree object, and a file's bytes are fetched from the 4 + // object store only when it is opened. This lets a hook see a checkout of the 5 + // pushed commit without materializing the whole tree. 6 + // 7 + // Every mutating operation returns billy.ErrReadOnly. 8 + package treefs 9 + 10 + import ( 11 + "fmt" 12 + "io" 13 + "io/fs" 14 + "os" 15 + "path" 16 + "time" 17 + 18 + "github.com/go-git/go-billy/v6" 19 + "github.com/go-git/go-git/v6/plumbing/filemode" 20 + "github.com/go-git/go-git/v6/plumbing/object" 21 + ) 22 + 23 + // FS is a read-only billy.Filesystem backed by a git tree. 24 + type FS struct { 25 + tree *object.Tree 26 + } 27 + 28 + // New returns a filesystem serving the contents of tree. 29 + func New(tree *object.Tree) *FS { 30 + return &FS{tree: tree} 31 + } 32 + 33 + // rel normalizes a billy path into a tree-relative path. The empty string and 34 + // "." denote the tree root. 35 + func rel(p string) string { 36 + p = path.Clean("/" + p) 37 + return p[1:] // strip leading slash; root becomes "" 38 + } 39 + 40 + func notExist(op, name string) error { 41 + return &os.PathError{Op: op, Path: name, Err: fs.ErrNotExist} 42 + } 43 + 44 + func (f *FS) Open(filename string) (billy.File, error) { 45 + return f.OpenFile(filename, os.O_RDONLY, 0) 46 + } 47 + 48 + func (f *FS) OpenFile(filename string, flag int, _ fs.FileMode) (billy.File, error) { 49 + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { 50 + return nil, billy.ErrReadOnly 51 + } 52 + r := rel(filename) 53 + if r == "" { 54 + return nil, &os.PathError{Op: "open", Path: filename, Err: fmt.Errorf("is a directory")} 55 + } 56 + file, err := f.tree.File(r) 57 + if err != nil { 58 + return nil, notExist("open", filename) 59 + } 60 + rc, err := file.Reader() 61 + if err != nil { 62 + return nil, err 63 + } 64 + defer rc.Close() 65 + data, err := io.ReadAll(rc) 66 + if err != nil { 67 + return nil, err 68 + } 69 + return newFile(path.Base(r), data, fileInfo{name: path.Base(r), size: int64(len(data)), mode: osMode(file.Mode)}), nil 70 + } 71 + 72 + func (f *FS) Stat(filename string) (fs.FileInfo, error) { 73 + r := rel(filename) 74 + if r == "" { 75 + return fileInfo{name: "/", mode: fs.ModeDir | 0o755}, nil 76 + } 77 + entry, err := f.tree.FindEntry(r) 78 + if err != nil { 79 + return nil, notExist("stat", filename) 80 + } 81 + info := fileInfo{name: path.Base(r), mode: osMode(entry.Mode)} 82 + if entry.Mode != filemode.Dir { 83 + if size, err := f.tree.Size(r); err == nil { 84 + info.size = size 85 + } 86 + } 87 + return info, nil 88 + } 89 + 90 + // Lstat behaves like Stat; tree entries already carry their own mode, so there 91 + // is no separate link to resolve. 92 + func (f *FS) Lstat(filename string) (fs.FileInfo, error) { return f.Stat(filename) } 93 + 94 + func (f *FS) ReadDir(p string) ([]fs.DirEntry, error) { 95 + r := rel(p) 96 + tree := f.tree 97 + if r != "" { 98 + sub, err := f.tree.Tree(r) 99 + if err != nil { 100 + return nil, notExist("readdir", p) 101 + } 102 + tree = sub 103 + } 104 + out := make([]fs.DirEntry, 0, len(tree.Entries)) 105 + for _, e := range tree.Entries { 106 + out = append(out, fileInfo{name: e.Name, mode: osMode(e.Mode)}) 107 + } 108 + return out, nil 109 + } 110 + 111 + func (f *FS) Readlink(link string) (string, error) { 112 + r := rel(link) 113 + entry, err := f.tree.FindEntry(r) 114 + if err != nil || entry.Mode != filemode.Symlink { 115 + return "", notExist("readlink", link) 116 + } 117 + file, err := f.tree.File(r) 118 + if err != nil { 119 + return "", notExist("readlink", link) 120 + } 121 + target, err := file.Contents() 122 + if err != nil { 123 + return "", err 124 + } 125 + return target, nil 126 + } 127 + 128 + // Chroot returns a filesystem rooted at the subtree under p. 129 + func (f *FS) Chroot(p string) (billy.Filesystem, error) { 130 + r := rel(p) 131 + if r == "" { 132 + return f, nil 133 + } 134 + sub, err := f.tree.Tree(r) 135 + if err != nil { 136 + return nil, notExist("chroot", p) 137 + } 138 + return New(sub), nil 139 + } 140 + 141 + func (f *FS) Root() string { return "/" } 142 + 143 + func (f *FS) Join(elem ...string) string { return path.Join(elem...) } 144 + 145 + // Mutating operations are unsupported on a read-only tree. 146 + func (f *FS) Create(string) (billy.File, error) { return nil, billy.ErrReadOnly } 147 + func (f *FS) Rename(string, string) error { return billy.ErrReadOnly } 148 + func (f *FS) Remove(string) error { return billy.ErrReadOnly } 149 + func (f *FS) MkdirAll(string, fs.FileMode) error { return billy.ErrReadOnly } 150 + func (f *FS) Symlink(string, string) error { return billy.ErrReadOnly } 151 + func (f *FS) TempFile(string, string) (billy.File, error) { return nil, billy.ErrReadOnly } 152 + 153 + // osMode maps a git filemode to an fs.FileMode for stat results. 154 + func osMode(m filemode.FileMode) fs.FileMode { 155 + switch m { 156 + case filemode.Dir: 157 + return fs.ModeDir | 0o755 158 + case filemode.Symlink: 159 + return fs.ModeSymlink | 0o777 160 + case filemode.Executable: 161 + return 0o755 162 + default: 163 + return 0o644 164 + } 165 + } 166 + 167 + // fileInfo implements both fs.FileInfo and fs.DirEntry for tree entries. 168 + type fileInfo struct { 169 + name string 170 + size int64 171 + mode fs.FileMode 172 + } 173 + 174 + func (i fileInfo) Name() string { return i.name } 175 + func (i fileInfo) Size() int64 { return i.size } 176 + func (i fileInfo) Mode() fs.FileMode { return i.mode } 177 + func (i fileInfo) ModTime() time.Time { return time.Time{} } 178 + func (i fileInfo) IsDir() bool { return i.mode.IsDir() } 179 + func (i fileInfo) Sys() any { return nil } 180 + func (i fileInfo) Type() fs.FileMode { return i.mode.Type() } 181 + func (i fileInfo) Info() (fs.FileInfo, error) { return i, nil } 182 + 183 + var ( 184 + _ billy.Filesystem = (*FS)(nil) 185 + _ fs.FileInfo = fileInfo{} 186 + _ fs.DirEntry = fileInfo{} 187 + )
+197
internal/treefs/treefs_test.go
··· 1 + package treefs 2 + 3 + import ( 4 + "errors" 5 + "io" 6 + "os" 7 + "sort" 8 + "testing" 9 + 10 + "github.com/go-git/go-billy/v6" 11 + "github.com/go-git/go-git/v6/plumbing" 12 + "github.com/go-git/go-git/v6/plumbing/filemode" 13 + "github.com/go-git/go-git/v6/plumbing/object" 14 + "github.com/go-git/go-git/v6/storage" 15 + "github.com/go-git/go-git/v6/storage/memory" 16 + ) 17 + 18 + // buildTree writes a small tree (a regular file, a nested directory, and an 19 + // executable script) directly into an in-memory object store and returns its 20 + // root tree. Building objects by hand avoids depending on the host git config 21 + // (e.g. commit.gpgSign) and pins the file modes precisely. 22 + func buildTree(t *testing.T) *object.Tree { 23 + t.Helper() 24 + store := memory.NewStorage() 25 + 26 + readme := putBlob(t, store, "hello\n") 27 + nested := putBlob(t, store, "deep\n") 28 + run := putBlob(t, store, "#!/bin/sh\necho hi\n") 29 + 30 + dir := putTree(t, store, []object.TreeEntry{ 31 + {Name: "nested.txt", Mode: filemode.Regular, Hash: nested}, 32 + }) 33 + root := putTree(t, store, []object.TreeEntry{ 34 + {Name: "README.md", Mode: filemode.Regular, Hash: readme}, 35 + {Name: "dir", Mode: filemode.Dir, Hash: dir}, 36 + {Name: "run.sh", Mode: filemode.Executable, Hash: run}, 37 + }) 38 + 39 + tree, err := object.GetTree(store, root) 40 + if err != nil { 41 + t.Fatalf("get tree: %v", err) 42 + } 43 + return tree 44 + } 45 + 46 + func putBlob(t *testing.T, store storage.Storer, data string) plumbing.Hash { 47 + t.Helper() 48 + o := store.NewEncodedObject() 49 + o.SetType(plumbing.BlobObject) 50 + w, err := o.Writer() 51 + if err != nil { 52 + t.Fatalf("blob writer: %v", err) 53 + } 54 + if _, err := io.WriteString(w, data); err != nil { 55 + t.Fatalf("blob write: %v", err) 56 + } 57 + _ = w.Close() 58 + h, err := store.SetEncodedObject(o) 59 + if err != nil { 60 + t.Fatalf("set blob: %v", err) 61 + } 62 + return h 63 + } 64 + 65 + func putTree(t *testing.T, store storage.Storer, entries []object.TreeEntry) plumbing.Hash { 66 + t.Helper() 67 + sort.Sort(object.TreeEntrySorter(entries)) 68 + tree := &object.Tree{Entries: entries} 69 + o := store.NewEncodedObject() 70 + if err := tree.Encode(o); err != nil { 71 + t.Fatalf("encode tree: %v", err) 72 + } 73 + h, err := store.SetEncodedObject(o) 74 + if err != nil { 75 + t.Fatalf("set tree: %v", err) 76 + } 77 + return h 78 + } 79 + 80 + func TestOpenReadsFileContents(t *testing.T) { 81 + fs := New(buildTree(t)) 82 + 83 + for _, tc := range []struct { 84 + path string 85 + want string 86 + }{ 87 + {"README.md", "hello\n"}, 88 + {"/README.md", "hello\n"}, 89 + {"dir/nested.txt", "deep\n"}, 90 + } { 91 + f, err := fs.Open(tc.path) 92 + if err != nil { 93 + t.Fatalf("open %q: %v", tc.path, err) 94 + } 95 + data, err := io.ReadAll(f) 96 + _ = f.Close() 97 + if err != nil { 98 + t.Fatalf("read %q: %v", tc.path, err) 99 + } 100 + if string(data) != tc.want { 101 + t.Errorf("open %q = %q, want %q", tc.path, data, tc.want) 102 + } 103 + } 104 + } 105 + 106 + func TestStatModesAndSize(t *testing.T) { 107 + fs := New(buildTree(t)) 108 + 109 + info, err := fs.Stat("README.md") 110 + if err != nil { 111 + t.Fatalf("stat README.md: %v", err) 112 + } 113 + if info.IsDir() { 114 + t.Error("README.md reported as dir") 115 + } 116 + if info.Size() != int64(len("hello\n")) { 117 + t.Errorf("README.md size = %d, want %d", info.Size(), len("hello\n")) 118 + } 119 + 120 + dirInfo, err := fs.Stat("dir") 121 + if err != nil { 122 + t.Fatalf("stat dir: %v", err) 123 + } 124 + if !dirInfo.IsDir() { 125 + t.Error("dir not reported as dir") 126 + } 127 + 128 + exec, err := fs.Stat("run.sh") 129 + if err != nil { 130 + t.Fatalf("stat run.sh: %v", err) 131 + } 132 + if exec.Mode()&0o111 == 0 { 133 + t.Errorf("run.sh mode = %v, want executable bit set", exec.Mode()) 134 + } 135 + } 136 + 137 + func TestReadDir(t *testing.T) { 138 + fs := New(buildTree(t)) 139 + 140 + entries, err := fs.ReadDir("/") 141 + if err != nil { 142 + t.Fatalf("readdir root: %v", err) 143 + } 144 + got := map[string]bool{} 145 + for _, e := range entries { 146 + got[e.Name()] = e.IsDir() 147 + } 148 + if _, ok := got["README.md"]; !ok { 149 + t.Error("root listing missing README.md") 150 + } 151 + if isDir, ok := got["dir"]; !ok || !isDir { 152 + t.Errorf("root listing missing dir/ (got %v)", got) 153 + } 154 + 155 + nested, err := fs.ReadDir("dir") 156 + if err != nil { 157 + t.Fatalf("readdir dir: %v", err) 158 + } 159 + if len(nested) != 1 || nested[0].Name() != "nested.txt" { 160 + t.Errorf("dir listing = %v, want [nested.txt]", nested) 161 + } 162 + } 163 + 164 + func TestMissingPaths(t *testing.T) { 165 + fs := New(buildTree(t)) 166 + 167 + if _, err := fs.Open("does-not-exist"); !errors.Is(err, os.ErrNotExist) { 168 + t.Errorf("open missing = %v, want ErrNotExist", err) 169 + } 170 + if _, err := fs.Stat("nope/also-nope"); !errors.Is(err, os.ErrNotExist) { 171 + t.Errorf("stat missing = %v, want ErrNotExist", err) 172 + } 173 + } 174 + 175 + func TestWritesRejected(t *testing.T) { 176 + fs := New(buildTree(t)) 177 + 178 + if _, err := fs.Create("new.txt"); !errors.Is(err, billy.ErrReadOnly) { 179 + t.Errorf("Create = %v, want ErrReadOnly", err) 180 + } 181 + if _, err := fs.OpenFile("README.md", os.O_WRONLY, 0); !errors.Is(err, billy.ErrReadOnly) { 182 + t.Errorf("OpenFile(write) = %v, want ErrReadOnly", err) 183 + } 184 + if err := fs.Remove("README.md"); !errors.Is(err, billy.ErrReadOnly) { 185 + t.Errorf("Remove = %v, want ErrReadOnly", err) 186 + } 187 + 188 + // A read-opened handle must still refuse writes. 189 + f, err := fs.Open("README.md") 190 + if err != nil { 191 + t.Fatalf("open: %v", err) 192 + } 193 + defer f.Close() 194 + if _, err := f.Write([]byte("x")); !errors.Is(err, billy.ErrReadOnly) { 195 + t.Errorf("file.Write = %v, want ErrReadOnly", err) 196 + } 197 + }