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