detached d949cad

feat(githttp): add the hook socket, hook scripts and the ermite hook command

commit
d949cad503436e92eba632c197e3fc50bca456dd
author
brice · · 2026-10-10 07:10 UTC
parent
16a8861
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +933 -1
A cmd/ermite/hook.go +38 -0
@@ -0,0 +1,38 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "os"
7+
8+ "github.com/spf13/cobra"
9+
10+ "bnema.dev/ermite/internal/adapters/githttp"
11+)
12+
13+// newHookCmd is the hidden command that git's receive hooks run (through the
14+// scripts in run/hooks): it forwards the pushed ref updates to the server.
15+func newHookCmd() *cobra.Command {
16+ hook := &cobra.Command{
17+ Use: "hook",
18+ Short: "Run a Git receive hook (called by git, not by users)",
19+ Hidden: true,
20+ Args: cobra.NoArgs,
21+ }
22+ for _, name := range []string{githttp.HookPreReceive, githttp.HookPostReceive} {
23+ hook.AddCommand(&cobra.Command{
24+ Use: name,
25+ Short: "Report the pushed ref updates to the server",
26+ Hidden: true,
27+ Args: cobra.NoArgs,
28+ // A refusal is the exit status; its message is already on stderr.
29+ SilenceErrors: true,
30+ SilenceUsage: true,
31+ RunE: func(cmd *cobra.Command, _ []string) error {
32+ return githttp.RunHook(cmd.Context(), name, cmd.InOrStdin(), cmd.ErrOrStderr(),
33+ os.Getenv(githttp.EnvHookSocket), os.Getenv(githttp.EnvHookToken))
34+ },
35+ })
36+ }
37+ return hook
38+}
@@ -0,0 +1,38 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "os"
7+
8+ "github.com/spf13/cobra"
9+
10+ "bnema.dev/ermite/internal/adapters/githttp"
11+)
12+
13+// newHookCmd is the hidden command that git's receive hooks run (through the
14+// scripts in run/hooks): it forwards the pushed ref updates to the server.
15+func newHookCmd() *cobra.Command {
16+ hook := &cobra.Command{
17+ Use: "hook",
18+ Short: "Run a Git receive hook (called by git, not by users)",
19+ Hidden: true,
20+ Args: cobra.NoArgs,
21+ }
22+ for _, name := range []string{githttp.HookPreReceive, githttp.HookPostReceive} {
23+ hook.AddCommand(&cobra.Command{
24+ Use: name,
25+ Short: "Report the pushed ref updates to the server",
26+ Hidden: true,
27+ Args: cobra.NoArgs,
28+ // A refusal is the exit status; its message is already on stderr.
29+ SilenceErrors: true,
30+ SilenceUsage: true,
31+ RunE: func(cmd *cobra.Command, _ []string) error {
32+ return githttp.RunHook(cmd.Context(), name, cmd.InOrStdin(), cmd.ErrOrStderr(),
33+ os.Getenv(githttp.EnvHookSocket), os.Getenv(githttp.EnvHookToken))
34+ },
35+ })
36+ }
37+ return hook
38+}
M cmd/ermite/root.go +1 -1
@@ -18,7 +18,7 @@ func newRootCmd() *cobra.Command {
1818 DisableDefaultCmd: true,
1919 },
2020 }
21- root.AddCommand(newServeCmd(), newVersionCmd())
21+ root.AddCommand(newServeCmd(), newVersionCmd(), newHookCmd())
2222 return root
2323 }
2424
@@ -18,7 +18,7 @@ func newRootCmd() *cobra.Command {
18 DisableDefaultCmd: true,18 DisableDefaultCmd: true,
19 },19 },
20 }20 }
21- root.AddCommand(newServeCmd(), newVersionCmd())21+ root.AddCommand(newServeCmd(), newVersionCmd(), newHookCmd())
22 return root22 return root
23 }23 }
24 24
A internal/adapters/githttp/hookclient.go +114 -0
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "bufio"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "net"
13+ "strings"
14+ "time"
15+)
16+
17+// hookDialTimeout bounds connecting to the socket and the whole exchange.
18+const (
19+ hookDialTimeout = 5 * time.Second
20+ hookCallTimeout = 2 * time.Minute
21+)
22+
23+// ErrHookRejected is returned by RunHook when ermite refuses the push.
24+var ErrHookRejected = errors.New("push refused")
25+
26+// RunHook is `ermite hook <hook>`: it reads the ref updates git feeds a
27+// receive hook ("<old> <new> <ref>" per line) from in, sends them with the
28+// token to ermite over the socket, and writes ermite's message to errOut.
29+//
30+// For pre-receive it fails closed: a missing socket or token, an unreachable
31+// or erroring server, or a refusal all return an error (git then rejects the
32+// push). For post-receive the push already happened: failures are reported on
33+// errOut and RunHook returns nil.
34+func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
35+ err := callHook(ctx, hook, in, errOut, socket, token)
36+ if err == nil {
37+ return nil
38+ }
39+ if hook == HookPostReceive {
40+ _, _ = fmt.Fprintf(errOut, "ermite: %s: %v\n", hook, err)
41+ return nil
42+ }
43+ return err
44+}
45+
46+func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
47+ if hook != HookPreReceive && hook != HookPostReceive {
48+ return fmt.Errorf("unknown hook %q", hook)
49+ }
50+ if socket == "" || token == "" {
51+ return fmt.Errorf("%w: %s and %s are not set (is this push going through ermite?)", ErrHookRejected, EnvHookSocket, EnvHookToken)
52+ }
53+ updates, err := readUpdates(in)
54+ if err != nil {
55+ return fmt.Errorf("%w: %v", ErrHookRejected, err)
56+ }
57+
58+ ctx, cancel := context.WithTimeout(ctx, hookCallTimeout)
59+ defer cancel()
60+ d := net.Dialer{Timeout: hookDialTimeout}
61+ conn, err := d.DialContext(ctx, "unix", socket)
62+ if err != nil {
63+ return fmt.Errorf("%w: ermite is unreachable: %v", ErrHookRejected, err)
64+ }
65+ defer func() { _ = conn.Close() }()
66+ if dl, ok := ctx.Deadline(); ok {
67+ _ = conn.SetDeadline(dl)
68+ }
69+
70+ req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates})
71+ if err != nil {
72+ return err
73+ }
74+ if _, err := conn.Write(req); err != nil {
75+ return fmt.Errorf("%w: send to ermite: %v", ErrHookRejected, err)
76+ }
77+ line, err := readLine(conn)
78+ if err != nil {
79+ return fmt.Errorf("%w: no reply from ermite: %v", ErrHookRejected, err)
80+ }
81+ var reply HookReply
82+ if err := json.Unmarshal(line, &reply); err != nil {
83+ return fmt.Errorf("%w: malformed reply from ermite", ErrHookRejected)
84+ }
85+ if reply.Message != "" {
86+ _, _ = fmt.Fprintln(errOut, reply.Message)
87+ }
88+ if !reply.OK {
89+ return ErrHookRejected
90+ }
91+ return nil
92+}
93+
94+// readUpdates parses the lines git writes to a receive hook.
95+func readUpdates(in io.Reader) ([]HookUpdate, error) {
96+ var out []HookUpdate
97+ sc := bufio.NewScanner(in)
98+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
99+ for sc.Scan() {
100+ line := strings.TrimSpace(sc.Text())
101+ if line == "" {
102+ continue
103+ }
104+ f := strings.SplitN(line, " ", 3)
105+ if len(f) != 3 {
106+ return nil, fmt.Errorf("unexpected hook input %q", line)
107+ }
108+ out = append(out, HookUpdate{Old: f[0], New: f[1], Ref: f[2]})
109+ }
110+ if err := sc.Err(); err != nil {
111+ return nil, fmt.Errorf("read hook input: %w", err)
112+ }
113+ return out, nil
114+}
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "bufio"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "net"
13+ "strings"
14+ "time"
15+)
16+
17+// hookDialTimeout bounds connecting to the socket and the whole exchange.
18+const (
19+ hookDialTimeout = 5 * time.Second
20+ hookCallTimeout = 2 * time.Minute
21+)
22+
23+// ErrHookRejected is returned by RunHook when ermite refuses the push.
24+var ErrHookRejected = errors.New("push refused")
25+
26+// RunHook is `ermite hook <hook>`: it reads the ref updates git feeds a
27+// receive hook ("<old> <new> <ref>" per line) from in, sends them with the
28+// token to ermite over the socket, and writes ermite's message to errOut.
29+//
30+// For pre-receive it fails closed: a missing socket or token, an unreachable
31+// or erroring server, or a refusal all return an error (git then rejects the
32+// push). For post-receive the push already happened: failures are reported on
33+// errOut and RunHook returns nil.
34+func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
35+ err := callHook(ctx, hook, in, errOut, socket, token)
36+ if err == nil {
37+ return nil
38+ }
39+ if hook == HookPostReceive {
40+ _, _ = fmt.Fprintf(errOut, "ermite: %s: %v\n", hook, err)
41+ return nil
42+ }
43+ return err
44+}
45+
46+func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
47+ if hook != HookPreReceive && hook != HookPostReceive {
48+ return fmt.Errorf("unknown hook %q", hook)
49+ }
50+ if socket == "" || token == "" {
51+ return fmt.Errorf("%w: %s and %s are not set (is this push going through ermite?)", ErrHookRejected, EnvHookSocket, EnvHookToken)
52+ }
53+ updates, err := readUpdates(in)
54+ if err != nil {
55+ return fmt.Errorf("%w: %v", ErrHookRejected, err)
56+ }
57+
58+ ctx, cancel := context.WithTimeout(ctx, hookCallTimeout)
59+ defer cancel()
60+ d := net.Dialer{Timeout: hookDialTimeout}
61+ conn, err := d.DialContext(ctx, "unix", socket)
62+ if err != nil {
63+ return fmt.Errorf("%w: ermite is unreachable: %v", ErrHookRejected, err)
64+ }
65+ defer func() { _ = conn.Close() }()
66+ if dl, ok := ctx.Deadline(); ok {
67+ _ = conn.SetDeadline(dl)
68+ }
69+
70+ req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates})
71+ if err != nil {
72+ return err
73+ }
74+ if _, err := conn.Write(req); err != nil {
75+ return fmt.Errorf("%w: send to ermite: %v", ErrHookRejected, err)
76+ }
77+ line, err := readLine(conn)
78+ if err != nil {
79+ return fmt.Errorf("%w: no reply from ermite: %v", ErrHookRejected, err)
80+ }
81+ var reply HookReply
82+ if err := json.Unmarshal(line, &reply); err != nil {
83+ return fmt.Errorf("%w: malformed reply from ermite", ErrHookRejected)
84+ }
85+ if reply.Message != "" {
86+ _, _ = fmt.Fprintln(errOut, reply.Message)
87+ }
88+ if !reply.OK {
89+ return ErrHookRejected
90+ }
91+ return nil
92+}
93+
94+// readUpdates parses the lines git writes to a receive hook.
95+func readUpdates(in io.Reader) ([]HookUpdate, error) {
96+ var out []HookUpdate
97+ sc := bufio.NewScanner(in)
98+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
99+ for sc.Scan() {
100+ line := strings.TrimSpace(sc.Text())
101+ if line == "" {
102+ continue
103+ }
104+ f := strings.SplitN(line, " ", 3)
105+ if len(f) != 3 {
106+ return nil, fmt.Errorf("unexpected hook input %q", line)
107+ }
108+ out = append(out, HookUpdate{Old: f[0], New: f[1], Ref: f[2]})
109+ }
110+ if err := sc.Err(); err != nil {
111+ return nil, fmt.Errorf("read hook input: %w", err)
112+ }
113+ return out, nil
114+}
A internal/adapters/githttp/hooks.go +256 -0
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "bufio"
7+ "context"
8+ "crypto/rand"
9+ "encoding/hex"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "net"
15+ "os"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+)
23+
24+// PushHooks is what the hook socket asks of the use cases. app.Pushes fits.
25+type PushHooks interface {
26+ // Check decides a push before Git applies it; an error matching
27+ // domain.ErrRejected refuses it, any other error refuses it as a failure.
28+ Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
29+ // Record handles an accepted push.
30+ Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
31+}
32+
33+// readTimeout bounds how long a connection may take to send its request line.
34+const readTimeout = 30 * time.Second
35+
36+type hookEntry struct {
37+ repo domain.RepoName
38+ principal domain.Principal
39+}
40+
41+// HookServer registers the per-request hook tokens and serves the Unix
42+// socket the hooks call back on. It implements HookTokens.
43+type HookServer struct {
44+ path string
45+ hooks PushHooks
46+
47+ mu sync.RWMutex
48+ tokens map[string]hookEntry
49+
50+ connMu sync.Mutex
51+ ln net.Listener
52+ conns map[net.Conn]struct{}
53+ wg sync.WaitGroup
54+}
55+
56+var _ HookTokens = (*HookServer)(nil)
57+
58+// NewHookServer builds a server for the socket at path. Call Serve to listen.
59+func NewHookServer(path string, hooks PushHooks) *HookServer {
60+ return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
61+}
62+
63+// Socket returns the path of the socket.
64+func (s *HookServer) Socket() string { return s.path }
65+
66+// Register binds a fresh random token to the repository and principal of a
67+// push. The returned release revokes it.
68+func (s *HookServer) Register(repo domain.RepoName, p domain.Principal) (string, func()) {
69+ var raw [32]byte
70+ if _, err := rand.Read(raw[:]); err != nil {
71+ // No entropy: hand out a token that matches nothing, so the hooks fail closed.
72+ return "unavailable", func() {}
73+ }
74+ token := hex.EncodeToString(raw[:])
75+ s.mu.Lock()
76+ s.tokens[token] = hookEntry{repo: repo, principal: p}
77+ s.mu.Unlock()
78+ return token, func() {
79+ s.mu.Lock()
80+ delete(s.tokens, token)
81+ s.mu.Unlock()
82+ }
83+}
84+
85+func (s *HookServer) lookup(token string) (hookEntry, bool) {
86+ s.mu.RLock()
87+ defer s.mu.RUnlock()
88+ e, ok := s.tokens[token]
89+ return e, ok
90+}
91+
92+// Listen creates the socket with mode 0600 (replacing a stale file) and
93+// returns once it accepts connections. Serve then handles them.
94+func (s *HookServer) Listen() error {
95+ // sun_path holds 108 bytes on Linux, 104 on the BSDs and macOS.
96+ if len(s.path) >= 100 {
97+ return fmt.Errorf("hook socket path is too long for a Unix socket (%d bytes, limit about 100): %s; use a shorter %s", len(s.path), s.path, "ERMITE_DATA_DIR")
98+ }
99+ if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
100+ return fmt.Errorf("remove stale hook socket: %w", err)
101+ }
102+ ln, err := net.Listen("unix", s.path)
103+ if err != nil {
104+ return fmt.Errorf("listen on hook socket %s: %w", s.path, err)
105+ }
106+ if err := os.Chmod(s.path, 0o600); err != nil {
107+ _ = ln.Close()
108+ return fmt.Errorf("chmod hook socket: %w", err)
109+ }
110+ s.connMu.Lock()
111+ s.ln = ln
112+ s.connMu.Unlock()
113+ return nil
114+}
115+
116+// Serve accepts connections until ctx ends or Close is called, then waits for
117+// the requests in progress. The logger of ctx is used throughout. It returns
118+// nil after a clean stop.
119+func (s *HookServer) Serve(ctx context.Context) error {
120+ s.connMu.Lock()
121+ ln := s.ln
122+ s.connMu.Unlock()
123+ if ln == nil {
124+ return errors.New("hook server is not listening")
125+ }
126+ log := zerowrap.FromCtx(ctx)
127+ log.Info().Str("socket", s.path).Msg("hook socket listening")
128+
129+ stopped := make(chan struct{})
130+ go func() {
131+ select {
132+ case <-ctx.Done():
133+ case <-stopped:
134+ }
135+ _ = ln.Close()
136+ }()
137+ defer close(stopped)
138+
139+ for {
140+ conn, err := ln.Accept()
141+ if err != nil {
142+ s.wg.Wait()
143+ _ = os.Remove(s.path)
144+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
145+ return nil
146+ }
147+ return fmt.Errorf("accept on hook socket: %w", err)
148+ }
149+ s.connMu.Lock()
150+ s.conns[conn] = struct{}{}
151+ s.connMu.Unlock()
152+ s.wg.Add(1)
153+ go func() {
154+ defer s.wg.Done()
155+ defer func() {
156+ _ = conn.Close()
157+ s.connMu.Lock()
158+ delete(s.conns, conn)
159+ s.connMu.Unlock()
160+ }()
161+ s.handle(ctx, conn)
162+ }()
163+ }
164+}
165+
166+// Close stops listening and drops the connections in progress.
167+func (s *HookServer) Close() {
168+ s.connMu.Lock()
169+ defer s.connMu.Unlock()
170+ if s.ln != nil {
171+ _ = s.ln.Close()
172+ }
173+ for c := range s.conns {
174+ _ = c.Close()
175+ }
176+}
177+
178+// handle serves one connection: one request line, one reply line.
179+func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
180+ log := zerowrap.FromCtx(ctx)
181+ _ = conn.SetReadDeadline(time.Now().Add(readTimeout))
182+ line, err := readLine(conn)
183+ if err != nil {
184+ log.Warn().Err(err).Msg("read hook request")
185+ s.reply(ctx, conn, HookReply{Message: "ermite: malformed hook request"})
186+ return
187+ }
188+ _ = conn.SetReadDeadline(time.Time{})
189+
190+ var req HookRequest
191+ if err := json.Unmarshal(line, &req); err != nil {
192+ log.Warn().Err(err).Msg("decode hook request")
193+ s.reply(ctx, conn, HookReply{Message: "ermite: malformed hook request"})
194+ return
195+ }
196+ updates, err := req.refUpdates()
197+ if err != nil {
198+ log.Warn().Err(err).Str("hook", req.Hook).Msg("invalid hook request")
199+ s.reply(ctx, conn, HookReply{Message: "ermite: invalid hook request"})
200+ return
201+ }
202+ entry, ok := s.lookup(req.Token)
203+ if !ok {
204+ log.Warn().Str("hook", req.Hook).Msg("hook request with an unknown or expired token")
205+ s.reply(ctx, conn, HookReply{Message: "ermite: unknown or expired hook token"})
206+ return
207+ }
208+
209+ rlog := log.WithField("repo", entry.repo.String())
210+ switch req.Hook {
211+ case HookPreReceive:
212+ err := s.hooks.Check(ctx, entry.principal, entry.repo, updates)
213+ switch {
214+ case err == nil:
215+ s.reply(ctx, conn, HookReply{OK: true})
216+ case errors.Is(err, domain.ErrRejected):
217+ rlog.Warn().Err(err).Int("refs", len(updates)).Msg("push rejected by pre-receive")
218+ s.reply(ctx, conn, HookReply{Message: "ermite: push rejected: " + err.Error()})
219+ default:
220+ rlog.Error().Err(err).Msg("pre-receive check failed")
221+ s.reply(ctx, conn, HookReply{Message: "ermite: push refused: internal error"})
222+ }
223+ case HookPostReceive:
224+ if err := s.hooks.Record(ctx, entry.principal, entry.repo, updates); err != nil {
225+ rlog.Error().Err(err).Msg("post-receive recording failed")
226+ s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
227+ return
228+ }
229+ s.reply(ctx, conn, HookReply{OK: true})
230+ }
231+}
232+
233+func (s *HookServer) reply(ctx context.Context, conn net.Conn, r HookReply) {
234+ b, err := encodeLine(r)
235+ if err != nil {
236+ return
237+ }
238+ _ = conn.SetWriteDeadline(time.Now().Add(readTimeout))
239+ if _, err := conn.Write(b); err != nil {
240+ log := zerowrap.FromCtx(ctx)
241+ log.Warn().Err(err).Msg("write hook reply")
242+ }
243+}
244+
245+// readLine reads one line of at most maxHookMessage bytes.
246+func readLine(r io.Reader) ([]byte, error) {
247+ br := bufio.NewReaderSize(io.LimitReader(r, maxHookMessage+1), 64<<10)
248+ line, err := br.ReadBytes('\n')
249+ if err != nil && (!errors.Is(err, io.EOF) || len(line) == 0) {
250+ return nil, err
251+ }
252+ if len(line) > maxHookMessage {
253+ return nil, errors.New("hook message too long")
254+ }
255+ return line, nil
256+}
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "bufio"
7+ "context"
8+ "crypto/rand"
9+ "encoding/hex"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "net"
15+ "os"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+)
23+
24+// PushHooks is what the hook socket asks of the use cases. app.Pushes fits.
25+type PushHooks interface {
26+ // Check decides a push before Git applies it; an error matching
27+ // domain.ErrRejected refuses it, any other error refuses it as a failure.
28+ Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
29+ // Record handles an accepted push.
30+ Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
31+}
32+
33+// readTimeout bounds how long a connection may take to send its request line.
34+const readTimeout = 30 * time.Second
35+
36+type hookEntry struct {
37+ repo domain.RepoName
38+ principal domain.Principal
39+}
40+
41+// HookServer registers the per-request hook tokens and serves the Unix
42+// socket the hooks call back on. It implements HookTokens.
43+type HookServer struct {
44+ path string
45+ hooks PushHooks
46+
47+ mu sync.RWMutex
48+ tokens map[string]hookEntry
49+
50+ connMu sync.Mutex
51+ ln net.Listener
52+ conns map[net.Conn]struct{}
53+ wg sync.WaitGroup
54+}
55+
56+var _ HookTokens = (*HookServer)(nil)
57+
58+// NewHookServer builds a server for the socket at path. Call Serve to listen.
59+func NewHookServer(path string, hooks PushHooks) *HookServer {
60+ return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
61+}
62+
63+// Socket returns the path of the socket.
64+func (s *HookServer) Socket() string { return s.path }
65+
66+// Register binds a fresh random token to the repository and principal of a
67+// push. The returned release revokes it.
68+func (s *HookServer) Register(repo domain.RepoName, p domain.Principal) (string, func()) {
69+ var raw [32]byte
70+ if _, err := rand.Read(raw[:]); err != nil {
71+ // No entropy: hand out a token that matches nothing, so the hooks fail closed.
72+ return "unavailable", func() {}
73+ }
74+ token := hex.EncodeToString(raw[:])
75+ s.mu.Lock()
76+ s.tokens[token] = hookEntry{repo: repo, principal: p}
77+ s.mu.Unlock()
78+ return token, func() {
79+ s.mu.Lock()
80+ delete(s.tokens, token)
81+ s.mu.Unlock()
82+ }
83+}
84+
85+func (s *HookServer) lookup(token string) (hookEntry, bool) {
86+ s.mu.RLock()
87+ defer s.mu.RUnlock()
88+ e, ok := s.tokens[token]
89+ return e, ok
90+}
91+
92+// Listen creates the socket with mode 0600 (replacing a stale file) and
93+// returns once it accepts connections. Serve then handles them.
94+func (s *HookServer) Listen() error {
95+ // sun_path holds 108 bytes on Linux, 104 on the BSDs and macOS.
96+ if len(s.path) >= 100 {
97+ return fmt.Errorf("hook socket path is too long for a Unix socket (%d bytes, limit about 100): %s; use a shorter %s", len(s.path), s.path, "ERMITE_DATA_DIR")
98+ }
99+ if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
100+ return fmt.Errorf("remove stale hook socket: %w", err)
101+ }
102+ ln, err := net.Listen("unix", s.path)
103+ if err != nil {
104+ return fmt.Errorf("listen on hook socket %s: %w", s.path, err)
105+ }
106+ if err := os.Chmod(s.path, 0o600); err != nil {
107+ _ = ln.Close()
108+ return fmt.Errorf("chmod hook socket: %w", err)
109+ }
110+ s.connMu.Lock()
111+ s.ln = ln
112+ s.connMu.Unlock()
113+ return nil
114+}
115+
116+// Serve accepts connections until ctx ends or Close is called, then waits for
117+// the requests in progress. The logger of ctx is used throughout. It returns
118+// nil after a clean stop.
119+func (s *HookServer) Serve(ctx context.Context) error {
120+ s.connMu.Lock()
121+ ln := s.ln
122+ s.connMu.Unlock()
123+ if ln == nil {
124+ return errors.New("hook server is not listening")
125+ }
126+ log := zerowrap.FromCtx(ctx)
127+ log.Info().Str("socket", s.path).Msg("hook socket listening")
128+
129+ stopped := make(chan struct{})
130+ go func() {
131+ select {
132+ case <-ctx.Done():
133+ case <-stopped:
134+ }
135+ _ = ln.Close()
136+ }()
137+ defer close(stopped)
138+
139+ for {
140+ conn, err := ln.Accept()
141+ if err != nil {
142+ s.wg.Wait()
143+ _ = os.Remove(s.path)
144+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
145+ return nil
146+ }
147+ return fmt.Errorf("accept on hook socket: %w", err)
148+ }
149+ s.connMu.Lock()
150+ s.conns[conn] = struct{}{}
151+ s.connMu.Unlock()
152+ s.wg.Add(1)
153+ go func() {
154+ defer s.wg.Done()
155+ defer func() {
156+ _ = conn.Close()
157+ s.connMu.Lock()
158+ delete(s.conns, conn)
159+ s.connMu.Unlock()
160+ }()
161+ s.handle(ctx, conn)
162+ }()
163+ }
164+}
165+
166+// Close stops listening and drops the connections in progress.
167+func (s *HookServer) Close() {
168+ s.connMu.Lock()
169+ defer s.connMu.Unlock()
170+ if s.ln != nil {
171+ _ = s.ln.Close()
172+ }
173+ for c := range s.conns {
174+ _ = c.Close()
175+ }
176+}
177+
178+// handle serves one connection: one request line, one reply line.
179+func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
180+ log := zerowrap.FromCtx(ctx)
181+ _ = conn.SetReadDeadline(time.Now().Add(readTimeout))
182+ line, err := readLine(conn)
183+ if err != nil {
184+ log.Warn().Err(err).Msg("read hook request")
185+ s.reply(ctx, conn, HookReply{Message: "ermite: malformed hook request"})
186+ return
187+ }
188+ _ = conn.SetReadDeadline(time.Time{})
189+
190+ var req HookRequest
191+ if err := json.Unmarshal(line, &req); err != nil {
192+ log.Warn().Err(err).Msg("decode hook request")
193+ s.reply(ctx, conn, HookReply{Message: "ermite: malformed hook request"})
194+ return
195+ }
196+ updates, err := req.refUpdates()
197+ if err != nil {
198+ log.Warn().Err(err).Str("hook", req.Hook).Msg("invalid hook request")
199+ s.reply(ctx, conn, HookReply{Message: "ermite: invalid hook request"})
200+ return
201+ }
202+ entry, ok := s.lookup(req.Token)
203+ if !ok {
204+ log.Warn().Str("hook", req.Hook).Msg("hook request with an unknown or expired token")
205+ s.reply(ctx, conn, HookReply{Message: "ermite: unknown or expired hook token"})
206+ return
207+ }
208+
209+ rlog := log.WithField("repo", entry.repo.String())
210+ switch req.Hook {
211+ case HookPreReceive:
212+ err := s.hooks.Check(ctx, entry.principal, entry.repo, updates)
213+ switch {
214+ case err == nil:
215+ s.reply(ctx, conn, HookReply{OK: true})
216+ case errors.Is(err, domain.ErrRejected):
217+ rlog.Warn().Err(err).Int("refs", len(updates)).Msg("push rejected by pre-receive")
218+ s.reply(ctx, conn, HookReply{Message: "ermite: push rejected: " + err.Error()})
219+ default:
220+ rlog.Error().Err(err).Msg("pre-receive check failed")
221+ s.reply(ctx, conn, HookReply{Message: "ermite: push refused: internal error"})
222+ }
223+ case HookPostReceive:
224+ if err := s.hooks.Record(ctx, entry.principal, entry.repo, updates); err != nil {
225+ rlog.Error().Err(err).Msg("post-receive recording failed")
226+ s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
227+ return
228+ }
229+ s.reply(ctx, conn, HookReply{OK: true})
230+ }
231+}
232+
233+func (s *HookServer) reply(ctx context.Context, conn net.Conn, r HookReply) {
234+ b, err := encodeLine(r)
235+ if err != nil {
236+ return
237+ }
238+ _ = conn.SetWriteDeadline(time.Now().Add(readTimeout))
239+ if _, err := conn.Write(b); err != nil {
240+ log := zerowrap.FromCtx(ctx)
241+ log.Warn().Err(err).Msg("write hook reply")
242+ }
243+}
244+
245+// readLine reads one line of at most maxHookMessage bytes.
246+func readLine(r io.Reader) ([]byte, error) {
247+ br := bufio.NewReaderSize(io.LimitReader(r, maxHookMessage+1), 64<<10)
248+ line, err := br.ReadBytes('\n')
249+ if err != nil && (!errors.Is(err, io.EOF) || len(line) == 0) {
250+ return nil, err
251+ }
252+ if len(line) > maxHookMessage {
253+ return nil, errors.New("hook message too long")
254+ }
255+ return line, nil
256+}
A internal/adapters/githttp/hooks_test.go +386 -0
@@ -0,0 +1,386 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp_test
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "context"
9+ "encoding/json"
10+ "errors"
11+ "net"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "bnema.dev/ermite/internal/adapters/githttp"
24+ "bnema.dev/ermite/internal/domain"
25+)
26+
27+const (
28+ oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
29+ oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
30+)
31+
32+type call struct {
33+ hook string
34+ p domain.Principal
35+ repo domain.RepoName
36+ updates []domain.RefUpdate
37+}
38+
39+type fakeHooks struct {
40+ mu sync.Mutex
41+ calls []call
42+ checkErr error
43+ recErr error
44+}
45+
46+func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
47+ f.mu.Lock()
48+ defer f.mu.Unlock()
49+ f.calls = append(f.calls, call{"check", p, n, u})
50+ return f.checkErr
51+}
52+
53+func (f *fakeHooks) Record(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
54+ f.mu.Lock()
55+ defer f.mu.Unlock()
56+ f.calls = append(f.calls, call{"record", p, n, u})
57+ return f.recErr
58+}
59+
60+func (f *fakeHooks) last() call {
61+ f.mu.Lock()
62+ defer f.mu.Unlock()
63+ return f.calls[len(f.calls)-1]
64+}
65+
66+func repoName(t *testing.T, s string) domain.RepoName {
67+ t.Helper()
68+ n, err := domain.ParseRepoName(s)
69+ require.NoError(t, err)
70+ return n
71+}
72+
73+// startHooks serves a hook socket in a short temporary directory.
74+func startHooks(t *testing.T, h githttp.PushHooks) (*githttp.HookServer, string) {
75+ t.Helper()
76+ dir, err := os.MkdirTemp("", "hk")
77+ require.NoError(t, err)
78+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
79+ sock := filepath.Join(dir, "hook.sock")
80+ s := githttp.NewHookServer(sock, h)
81+ require.NoError(t, s.Listen())
82+ ctx, cancel := context.WithCancel(context.Background())
83+ done := make(chan error, 1)
84+ go func() { done <- s.Serve(ctx) }()
85+ t.Cleanup(func() {
86+ cancel()
87+ select {
88+ case err := <-done:
89+ assert.NoError(t, err)
90+ case <-time.After(5 * time.Second):
91+ t.Error("hook server did not stop")
92+ }
93+ })
94+ return s, sock
95+}
96+
97+// raw sends one line and reads the reply line.
98+func raw(t *testing.T, sock, line string) githttp.HookReply {
99+ t.Helper()
100+ conn, err := net.Dial("unix", sock)
101+ require.NoError(t, err)
102+ defer func() { _ = conn.Close() }()
103+ _, err = conn.Write([]byte(line + "\n"))
104+ require.NoError(t, err)
105+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
106+ reply, err := bufio.NewReader(conn).ReadBytes('\n')
107+ require.NoError(t, err)
108+ var r githttp.HookReply
109+ require.NoError(t, json.Unmarshal(reply, &r), string(reply))
110+ return r
111+}
112+
113+func TestHookSocketMode(t *testing.T) {
114+ _, sock := startHooks(t, &fakeHooks{})
115+ fi, err := os.Stat(sock)
116+ require.NoError(t, err)
117+ assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
118+ assert.NotZero(t, fi.Mode()&os.ModeSocket)
119+}
120+
121+func TestHookServerReplacesStaleSocketFile(t *testing.T) {
122+ dir, err := os.MkdirTemp("", "hk")
123+ require.NoError(t, err)
124+ defer func() { _ = os.RemoveAll(dir) }()
125+ sock := filepath.Join(dir, "hook.sock")
126+ require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
127+ s := githttp.NewHookServer(sock, &fakeHooks{})
128+ require.NoError(t, s.Listen())
129+ s.Close()
130+}
131+
132+func TestHookServerRejectsTooLongSocketPath(t *testing.T) {
133+ s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{})
134+ err := s.Listen()
135+ require.Error(t, err)
136+ assert.Contains(t, err.Error(), "too long")
137+}
138+
139+func TestHookProtocol(t *testing.T) {
140+ fh := &fakeHooks{}
141+ srv, sock := startHooks(t, fh)
142+ p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
143+ tokenA, releaseA := srv.Register(repoName(t, "alpha"), p)
144+ tokenB, releaseB := srv.Register(repoName(t, "beta"), domain.Root())
145+ assert.NotEqual(t, tokenA, tokenB)
146+ assert.Len(t, tokenA, 64)
147+
148+ upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
149+
150+ t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
151+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
152+ assert.True(t, r.OK)
153+ c := fh.last()
154+ assert.Equal(t, "check", c.hook)
155+ assert.Equal(t, repoName(t, "alpha"), c.repo)
156+ assert.Equal(t, "tok1", c.p.TokenID())
157+ assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates)
158+
159+ r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
160+ assert.True(t, r.OK)
161+ c = fh.last()
162+ assert.Equal(t, "record", c.hook)
163+ assert.Equal(t, repoName(t, "beta"), c.repo)
164+ assert.True(t, c.p.IsRoot())
165+ })
166+
167+ t.Run("a repository sent by the client is ignored", func(t *testing.T) {
168+ raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
169+ assert.Equal(t, repoName(t, "alpha"), fh.last().repo)
170+ })
171+
172+ t.Run("refusal carries the message", func(t *testing.T) {
173+ fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))
174+ defer func() { fh.checkErr = nil }()
175+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
176+ assert.False(t, r.OK)
177+ assert.Contains(t, r.Message, "rejected")
178+ })
179+
180+ t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
181+ fh.checkErr = errors.New("disk /secret/path exploded")
182+ defer func() { fh.checkErr = nil }()
183+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
184+ assert.False(t, r.OK)
185+ assert.NotContains(t, r.Message, "/secret/path")
186+ })
187+
188+ t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
189+ fh.recErr = errors.New("boom")
190+ defer func() { fh.recErr = nil }()
191+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
192+ assert.True(t, r.OK)
193+ assert.NotEmpty(t, r.Message)
194+ })
195+
196+ t.Run("unknown, empty and released tokens are refused", func(t *testing.T) {
197+ for _, tok := range []string{"nope", ""} {
198+ r := raw(t, sock, `{"token":"`+tok+`","hook":"pre-receive","updates":[]}`)
199+ assert.False(t, r.OK, tok)
200+ }
201+ releaseA()
202+ before := len(fh.calls)
203+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
204+ assert.False(t, r.OK, "released token")
205+ assert.Contains(t, r.Message, "token")
206+ assert.Len(t, fh.calls, before, "no use case ran")
207+ releaseB()
208+ })
209+
210+ t.Run("malformed requests are refused", func(t *testing.T) {
211+ tok, rel := srv.Register(repoName(t, "gamma"), domain.Root())
212+ defer rel()
213+ before := len(fh.calls)
214+ for _, line := range []string{
215+ `not json`,
216+ `{}`,
217+ `{"token":"` + tok + `","hook":"update","updates":[]}`,
218+ `{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"zz","new":"` + oidA + `","ref":"refs/heads/x"}]}`,
219+ `{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"` + oidA + `","new":"` + oidA + `","ref":""}]}`,
220+ } {
221+ r := raw(t, sock, line)
222+ assert.False(t, r.OK, line)
223+ }
224+ assert.Len(t, fh.calls, before)
225+ })
226+
227+ t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {
228+ conn, err := net.Dial("unix", sock)
229+ require.NoError(t, err)
230+ _ = conn.Close()
231+ r := raw(t, sock, `{"token":"x","hook":"pre-receive","updates":[]}`)
232+ assert.False(t, r.OK)
233+ })
234+}
235+
236+func TestHookConcurrentTokens(t *testing.T) {
237+ fh := &fakeHooks{}
238+ srv, sock := startHooks(t, fh)
239+ var wg sync.WaitGroup
240+ for i := range 16 {
241+ wg.Add(1)
242+ go func() {
243+ defer wg.Done()
244+ name := repoName(t, "r"+string(rune('a'+i)))
245+ tok, rel := srv.Register(name, domain.Root())
246+ defer rel()
247+ r := raw(t, sock, `{"token":"`+tok+`","hook":"post-receive","updates":[{"old":"`+domain.ZeroOID+`","new":"`+oidB+`","ref":"refs/heads/x"}]}`)
248+ assert.True(t, r.OK)
249+ }()
250+ }
251+ wg.Wait()
252+ seen := map[domain.RepoName]bool{}
253+ for _, c := range fh.calls {
254+ seen[c.repo] = true
255+ }
256+ assert.Len(t, seen, 16)
257+}
258+
259+func TestRunHook(t *testing.T) {
260+ ctx := context.Background()
261+ fh := &fakeHooks{}
262+ srv, sock := startHooks(t, fh)
263+ tok, rel := srv.Register(repoName(t, "alpha"), domain.Root())
264+ defer rel()
265+ input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
266+
267+ t.Run("sends the lines git feeds the hook", func(t *testing.T) {
268+ var errOut bytes.Buffer
269+ require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
270+ assert.Equal(t, []domain.RefUpdate{
271+ {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
272+ {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
273+ }, fh.last().updates)
274+ assert.Empty(t, errOut.String())
275+ })
276+
277+ t.Run("a ref name with spaces survives", func(t *testing.T) {
278+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
279+ assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref)
280+ })
281+
282+ t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
283+ fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))
284+ defer func() { fh.checkErr = nil }()
285+ var errOut bytes.Buffer
286+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
287+ require.ErrorIs(t, err, githttp.ErrHookRejected)
288+ assert.Contains(t, errOut.String(), "rejected")
289+ })
290+
291+ t.Run("pre-receive fails closed", func(t *testing.T) {
292+ for name, args := range map[string][2]string{
293+ "no socket": {"", tok},
294+ "no token": {sock, ""},
295+ "socket missing": {filepath.Join(t.TempDir(), "gone.sock"), tok},
296+ "unknown token": {sock, "unknown"},
297+ } {
298+ var errOut bytes.Buffer
299+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1])
300+ assert.ErrorIs(t, err, githttp.ErrHookRejected, name)
301+ }
302+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok)
303+ assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")
304+ assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok))
305+ })
306+
307+ t.Run("a server that never answers is cut off by the context", func(t *testing.T) {
308+ dir, err := os.MkdirTemp("", "hk")
309+ require.NoError(t, err)
310+ defer func() { _ = os.RemoveAll(dir) }()
311+ silent := filepath.Join(dir, "s.sock")
312+ ln, err := net.Listen("unix", silent)
313+ require.NoError(t, err)
314+ defer func() { _ = ln.Close() }()
315+ go func() {
316+ for {
317+ c, err := ln.Accept()
318+ if err != nil {
319+ return
320+ }
321+ defer func() { _ = c.Close() }()
322+ }
323+ }()
324+ cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
325+ defer cancel()
326+ start := time.Now()
327+ err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok)
328+ assert.ErrorIs(t, err, githttp.ErrHookRejected)
329+ assert.Less(t, time.Since(start), 5*time.Second)
330+ })
331+
332+ t.Run("post-receive never fails the push", func(t *testing.T) {
333+ var errOut bytes.Buffer
334+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok))
335+ assert.Contains(t, errOut.String(), "post-receive")
336+ errOut.Reset()
337+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", ""))
338+ assert.NotEmpty(t, errOut.String())
339+ })
340+}
341+
342+func TestWriteHookScripts(t *testing.T) {
343+ dir := filepath.Join(t.TempDir(), "run", "hooks")
344+ exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite"
345+ require.NoError(t, githttp.WriteHookScripts(dir, exe))
346+
347+ for _, hook := range []string{"pre-receive", "post-receive"} {
348+ path := filepath.Join(dir, hook)
349+ fi, err := os.Stat(path)
350+ require.NoError(t, err)
351+ assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
352+ body, err := os.ReadFile(path)
353+ require.NoError(t, err)
354+ assert.True(t, strings.HasPrefix(string(body), "#!/bin/sh\n"))
355+ assert.Contains(t, string(body), "exec '/opt/it'\\''s here/\"ermite\" $HOME `x`/ermite' hook "+hook+"\n")
356+ }
357+ // Rewriting replaces the files and leaves nothing behind.
358+ require.NoError(t, githttp.WriteHookScripts(dir, "/bin/true"))
359+ entries, err := os.ReadDir(dir)
360+ require.NoError(t, err)
361+ assert.Len(t, entries, 2)
362+
363+ assert.Error(t, githttp.WriteHookScripts(dir, "relative/ermite"))
364+ assert.Error(t, githttp.WriteHookScripts(dir, "/bin/er\nmite"))
365+}
366+
367+// The shell really passes the awkward path and the arguments through.
368+func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
369+ base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z")
370+ require.NoError(t, os.MkdirAll(base, 0o755))
371+ exe := filepath.Join(base, "fake ermite")
372+ out := filepath.Join(t.TempDir(), "args")
373+ require.NoError(t, os.WriteFile(exe, []byte("#!/bin/sh\nprintf '%s|' \"$@\" > '"+out+"'\ncat > '"+out+".stdin'\n"), 0o755))
374+ dir := t.TempDir()
375+ require.NoError(t, githttp.WriteHookScripts(dir, exe))
376+
377+ cmd := exec.Command(filepath.Join(dir, "pre-receive"))
378+ cmd.Stdin = strings.NewReader("line\n")
379+ require.NoError(t, cmd.Run())
380+ got, err := os.ReadFile(out)
381+ require.NoError(t, err)
382+ assert.Equal(t, "hook|pre-receive|", string(got))
383+ in, err := os.ReadFile(out + ".stdin")
384+ require.NoError(t, err)
385+ assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
386+}
@@ -0,0 +1,386 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp_test
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "context"
9+ "encoding/json"
10+ "errors"
11+ "net"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "bnema.dev/ermite/internal/adapters/githttp"
24+ "bnema.dev/ermite/internal/domain"
25+)
26+
27+const (
28+ oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
29+ oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
30+)
31+
32+type call struct {
33+ hook string
34+ p domain.Principal
35+ repo domain.RepoName
36+ updates []domain.RefUpdate
37+}
38+
39+type fakeHooks struct {
40+ mu sync.Mutex
41+ calls []call
42+ checkErr error
43+ recErr error
44+}
45+
46+func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
47+ f.mu.Lock()
48+ defer f.mu.Unlock()
49+ f.calls = append(f.calls, call{"check", p, n, u})
50+ return f.checkErr
51+}
52+
53+func (f *fakeHooks) Record(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
54+ f.mu.Lock()
55+ defer f.mu.Unlock()
56+ f.calls = append(f.calls, call{"record", p, n, u})
57+ return f.recErr
58+}
59+
60+func (f *fakeHooks) last() call {
61+ f.mu.Lock()
62+ defer f.mu.Unlock()
63+ return f.calls[len(f.calls)-1]
64+}
65+
66+func repoName(t *testing.T, s string) domain.RepoName {
67+ t.Helper()
68+ n, err := domain.ParseRepoName(s)
69+ require.NoError(t, err)
70+ return n
71+}
72+
73+// startHooks serves a hook socket in a short temporary directory.
74+func startHooks(t *testing.T, h githttp.PushHooks) (*githttp.HookServer, string) {
75+ t.Helper()
76+ dir, err := os.MkdirTemp("", "hk")
77+ require.NoError(t, err)
78+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
79+ sock := filepath.Join(dir, "hook.sock")
80+ s := githttp.NewHookServer(sock, h)
81+ require.NoError(t, s.Listen())
82+ ctx, cancel := context.WithCancel(context.Background())
83+ done := make(chan error, 1)
84+ go func() { done <- s.Serve(ctx) }()
85+ t.Cleanup(func() {
86+ cancel()
87+ select {
88+ case err := <-done:
89+ assert.NoError(t, err)
90+ case <-time.After(5 * time.Second):
91+ t.Error("hook server did not stop")
92+ }
93+ })
94+ return s, sock
95+}
96+
97+// raw sends one line and reads the reply line.
98+func raw(t *testing.T, sock, line string) githttp.HookReply {
99+ t.Helper()
100+ conn, err := net.Dial("unix", sock)
101+ require.NoError(t, err)
102+ defer func() { _ = conn.Close() }()
103+ _, err = conn.Write([]byte(line + "\n"))
104+ require.NoError(t, err)
105+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
106+ reply, err := bufio.NewReader(conn).ReadBytes('\n')
107+ require.NoError(t, err)
108+ var r githttp.HookReply
109+ require.NoError(t, json.Unmarshal(reply, &r), string(reply))
110+ return r
111+}
112+
113+func TestHookSocketMode(t *testing.T) {
114+ _, sock := startHooks(t, &fakeHooks{})
115+ fi, err := os.Stat(sock)
116+ require.NoError(t, err)
117+ assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
118+ assert.NotZero(t, fi.Mode()&os.ModeSocket)
119+}
120+
121+func TestHookServerReplacesStaleSocketFile(t *testing.T) {
122+ dir, err := os.MkdirTemp("", "hk")
123+ require.NoError(t, err)
124+ defer func() { _ = os.RemoveAll(dir) }()
125+ sock := filepath.Join(dir, "hook.sock")
126+ require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
127+ s := githttp.NewHookServer(sock, &fakeHooks{})
128+ require.NoError(t, s.Listen())
129+ s.Close()
130+}
131+
132+func TestHookServerRejectsTooLongSocketPath(t *testing.T) {
133+ s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{})
134+ err := s.Listen()
135+ require.Error(t, err)
136+ assert.Contains(t, err.Error(), "too long")
137+}
138+
139+func TestHookProtocol(t *testing.T) {
140+ fh := &fakeHooks{}
141+ srv, sock := startHooks(t, fh)
142+ p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
143+ tokenA, releaseA := srv.Register(repoName(t, "alpha"), p)
144+ tokenB, releaseB := srv.Register(repoName(t, "beta"), domain.Root())
145+ assert.NotEqual(t, tokenA, tokenB)
146+ assert.Len(t, tokenA, 64)
147+
148+ upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
149+
150+ t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
151+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
152+ assert.True(t, r.OK)
153+ c := fh.last()
154+ assert.Equal(t, "check", c.hook)
155+ assert.Equal(t, repoName(t, "alpha"), c.repo)
156+ assert.Equal(t, "tok1", c.p.TokenID())
157+ assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates)
158+
159+ r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
160+ assert.True(t, r.OK)
161+ c = fh.last()
162+ assert.Equal(t, "record", c.hook)
163+ assert.Equal(t, repoName(t, "beta"), c.repo)
164+ assert.True(t, c.p.IsRoot())
165+ })
166+
167+ t.Run("a repository sent by the client is ignored", func(t *testing.T) {
168+ raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
169+ assert.Equal(t, repoName(t, "alpha"), fh.last().repo)
170+ })
171+
172+ t.Run("refusal carries the message", func(t *testing.T) {
173+ fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))
174+ defer func() { fh.checkErr = nil }()
175+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
176+ assert.False(t, r.OK)
177+ assert.Contains(t, r.Message, "rejected")
178+ })
179+
180+ t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
181+ fh.checkErr = errors.New("disk /secret/path exploded")
182+ defer func() { fh.checkErr = nil }()
183+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
184+ assert.False(t, r.OK)
185+ assert.NotContains(t, r.Message, "/secret/path")
186+ })
187+
188+ t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
189+ fh.recErr = errors.New("boom")
190+ defer func() { fh.recErr = nil }()
191+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
192+ assert.True(t, r.OK)
193+ assert.NotEmpty(t, r.Message)
194+ })
195+
196+ t.Run("unknown, empty and released tokens are refused", func(t *testing.T) {
197+ for _, tok := range []string{"nope", ""} {
198+ r := raw(t, sock, `{"token":"`+tok+`","hook":"pre-receive","updates":[]}`)
199+ assert.False(t, r.OK, tok)
200+ }
201+ releaseA()
202+ before := len(fh.calls)
203+ r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
204+ assert.False(t, r.OK, "released token")
205+ assert.Contains(t, r.Message, "token")
206+ assert.Len(t, fh.calls, before, "no use case ran")
207+ releaseB()
208+ })
209+
210+ t.Run("malformed requests are refused", func(t *testing.T) {
211+ tok, rel := srv.Register(repoName(t, "gamma"), domain.Root())
212+ defer rel()
213+ before := len(fh.calls)
214+ for _, line := range []string{
215+ `not json`,
216+ `{}`,
217+ `{"token":"` + tok + `","hook":"update","updates":[]}`,
218+ `{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"zz","new":"` + oidA + `","ref":"refs/heads/x"}]}`,
219+ `{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"` + oidA + `","new":"` + oidA + `","ref":""}]}`,
220+ } {
221+ r := raw(t, sock, line)
222+ assert.False(t, r.OK, line)
223+ }
224+ assert.Len(t, fh.calls, before)
225+ })
226+
227+ t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {
228+ conn, err := net.Dial("unix", sock)
229+ require.NoError(t, err)
230+ _ = conn.Close()
231+ r := raw(t, sock, `{"token":"x","hook":"pre-receive","updates":[]}`)
232+ assert.False(t, r.OK)
233+ })
234+}
235+
236+func TestHookConcurrentTokens(t *testing.T) {
237+ fh := &fakeHooks{}
238+ srv, sock := startHooks(t, fh)
239+ var wg sync.WaitGroup
240+ for i := range 16 {
241+ wg.Add(1)
242+ go func() {
243+ defer wg.Done()
244+ name := repoName(t, "r"+string(rune('a'+i)))
245+ tok, rel := srv.Register(name, domain.Root())
246+ defer rel()
247+ r := raw(t, sock, `{"token":"`+tok+`","hook":"post-receive","updates":[{"old":"`+domain.ZeroOID+`","new":"`+oidB+`","ref":"refs/heads/x"}]}`)
248+ assert.True(t, r.OK)
249+ }()
250+ }
251+ wg.Wait()
252+ seen := map[domain.RepoName]bool{}
253+ for _, c := range fh.calls {
254+ seen[c.repo] = true
255+ }
256+ assert.Len(t, seen, 16)
257+}
258+
259+func TestRunHook(t *testing.T) {
260+ ctx := context.Background()
261+ fh := &fakeHooks{}
262+ srv, sock := startHooks(t, fh)
263+ tok, rel := srv.Register(repoName(t, "alpha"), domain.Root())
264+ defer rel()
265+ input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
266+
267+ t.Run("sends the lines git feeds the hook", func(t *testing.T) {
268+ var errOut bytes.Buffer
269+ require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
270+ assert.Equal(t, []domain.RefUpdate{
271+ {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
272+ {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
273+ }, fh.last().updates)
274+ assert.Empty(t, errOut.String())
275+ })
276+
277+ t.Run("a ref name with spaces survives", func(t *testing.T) {
278+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
279+ assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref)
280+ })
281+
282+ t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
283+ fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))
284+ defer func() { fh.checkErr = nil }()
285+ var errOut bytes.Buffer
286+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
287+ require.ErrorIs(t, err, githttp.ErrHookRejected)
288+ assert.Contains(t, errOut.String(), "rejected")
289+ })
290+
291+ t.Run("pre-receive fails closed", func(t *testing.T) {
292+ for name, args := range map[string][2]string{
293+ "no socket": {"", tok},
294+ "no token": {sock, ""},
295+ "socket missing": {filepath.Join(t.TempDir(), "gone.sock"), tok},
296+ "unknown token": {sock, "unknown"},
297+ } {
298+ var errOut bytes.Buffer
299+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1])
300+ assert.ErrorIs(t, err, githttp.ErrHookRejected, name)
301+ }
302+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok)
303+ assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")
304+ assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok))
305+ })
306+
307+ t.Run("a server that never answers is cut off by the context", func(t *testing.T) {
308+ dir, err := os.MkdirTemp("", "hk")
309+ require.NoError(t, err)
310+ defer func() { _ = os.RemoveAll(dir) }()
311+ silent := filepath.Join(dir, "s.sock")
312+ ln, err := net.Listen("unix", silent)
313+ require.NoError(t, err)
314+ defer func() { _ = ln.Close() }()
315+ go func() {
316+ for {
317+ c, err := ln.Accept()
318+ if err != nil {
319+ return
320+ }
321+ defer func() { _ = c.Close() }()
322+ }
323+ }()
324+ cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
325+ defer cancel()
326+ start := time.Now()
327+ err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok)
328+ assert.ErrorIs(t, err, githttp.ErrHookRejected)
329+ assert.Less(t, time.Since(start), 5*time.Second)
330+ })
331+
332+ t.Run("post-receive never fails the push", func(t *testing.T) {
333+ var errOut bytes.Buffer
334+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok))
335+ assert.Contains(t, errOut.String(), "post-receive")
336+ errOut.Reset()
337+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", ""))
338+ assert.NotEmpty(t, errOut.String())
339+ })
340+}
341+
342+func TestWriteHookScripts(t *testing.T) {
343+ dir := filepath.Join(t.TempDir(), "run", "hooks")
344+ exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite"
345+ require.NoError(t, githttp.WriteHookScripts(dir, exe))
346+
347+ for _, hook := range []string{"pre-receive", "post-receive"} {
348+ path := filepath.Join(dir, hook)
349+ fi, err := os.Stat(path)
350+ require.NoError(t, err)
351+ assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
352+ body, err := os.ReadFile(path)
353+ require.NoError(t, err)
354+ assert.True(t, strings.HasPrefix(string(body), "#!/bin/sh\n"))
355+ assert.Contains(t, string(body), "exec '/opt/it'\\''s here/\"ermite\" $HOME `x`/ermite' hook "+hook+"\n")
356+ }
357+ // Rewriting replaces the files and leaves nothing behind.
358+ require.NoError(t, githttp.WriteHookScripts(dir, "/bin/true"))
359+ entries, err := os.ReadDir(dir)
360+ require.NoError(t, err)
361+ assert.Len(t, entries, 2)
362+
363+ assert.Error(t, githttp.WriteHookScripts(dir, "relative/ermite"))
364+ assert.Error(t, githttp.WriteHookScripts(dir, "/bin/er\nmite"))
365+}
366+
367+// The shell really passes the awkward path and the arguments through.
368+func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
369+ base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z")
370+ require.NoError(t, os.MkdirAll(base, 0o755))
371+ exe := filepath.Join(base, "fake ermite")
372+ out := filepath.Join(t.TempDir(), "args")
373+ require.NoError(t, os.WriteFile(exe, []byte("#!/bin/sh\nprintf '%s|' \"$@\" > '"+out+"'\ncat > '"+out+".stdin'\n"), 0o755))
374+ dir := t.TempDir()
375+ require.NoError(t, githttp.WriteHookScripts(dir, exe))
376+
377+ cmd := exec.Command(filepath.Join(dir, "pre-receive"))
378+ cmd.Stdin = strings.NewReader("line\n")
379+ require.NoError(t, cmd.Run())
380+ got, err := os.ReadFile(out)
381+ require.NoError(t, err)
382+ assert.Equal(t, "hook|pre-receive|", string(got))
383+ in, err := os.ReadFile(out + ".stdin")
384+ require.NoError(t, err)
385+ assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
386+}
A internal/adapters/githttp/protocol.go +76 -0
@@ -0,0 +1,76 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+// Hook names, as used by git and by `ermite hook`.
14+const (
15+ HookPreReceive = "pre-receive"
16+ HookPostReceive = "post-receive"
17+)
18+
19+// maxHookMessage bounds one line of the hook protocol, request or reply.
20+const maxHookMessage = 32 << 20
21+
22+// HookRequest is the one JSON line a hook sends over the socket.
23+//
24+// {"token":"<hook token>","hook":"pre-receive","updates":[{"old":"<oid>","new":"<oid>","ref":"refs/heads/main"}]}
25+//
26+// The repository is not sent: the token is bound to it.
27+type HookRequest struct {
28+ Token string `json:"token"`
29+ Hook string `json:"hook"`
30+ Updates []HookUpdate `json:"updates"`
31+}
32+
33+// HookUpdate is one ref update, as git feeds it to the hook.
34+type HookUpdate struct {
35+ Old string `json:"old"`
36+ New string `json:"new"`
37+ Ref string `json:"ref"`
38+}
39+
40+// HookReply is the one JSON line ermite answers. Message is shown to the
41+// pusher (on stderr of the hook, so git prefixes it with "remote:").
42+//
43+// {"ok":true} {"ok":false,"message":"push rejected: ..."}
44+type HookReply struct {
45+ OK bool `json:"ok"`
46+ Message string `json:"message,omitempty"`
47+}
48+
49+// refUpdates checks the request fields that do not need the token.
50+func (r HookRequest) refUpdates() ([]domain.RefUpdate, error) {
51+ switch r.Hook {
52+ case HookPreReceive, HookPostReceive:
53+ default:
54+ return nil, fmt.Errorf("unknown hook %q", r.Hook)
55+ }
56+ if r.Token == "" {
57+ return nil, errors.New("missing token")
58+ }
59+ out := make([]domain.RefUpdate, 0, len(r.Updates))
60+ for _, u := range r.Updates {
61+ up, err := domain.NewRefUpdate(u.Old, u.New, u.Ref)
62+ if err != nil {
63+ return nil, err
64+ }
65+ out = append(out, up)
66+ }
67+ return out, nil
68+}
69+
70+func encodeLine(v any) ([]byte, error) {
71+ b, err := json.Marshal(v)
72+ if err != nil {
73+ return nil, err
74+ }
75+ return append(b, '\n'), nil
76+}
@@ -0,0 +1,76 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+// Hook names, as used by git and by `ermite hook`.
14+const (
15+ HookPreReceive = "pre-receive"
16+ HookPostReceive = "post-receive"
17+)
18+
19+// maxHookMessage bounds one line of the hook protocol, request or reply.
20+const maxHookMessage = 32 << 20
21+
22+// HookRequest is the one JSON line a hook sends over the socket.
23+//
24+// {"token":"<hook token>","hook":"pre-receive","updates":[{"old":"<oid>","new":"<oid>","ref":"refs/heads/main"}]}
25+//
26+// The repository is not sent: the token is bound to it.
27+type HookRequest struct {
28+ Token string `json:"token"`
29+ Hook string `json:"hook"`
30+ Updates []HookUpdate `json:"updates"`
31+}
32+
33+// HookUpdate is one ref update, as git feeds it to the hook.
34+type HookUpdate struct {
35+ Old string `json:"old"`
36+ New string `json:"new"`
37+ Ref string `json:"ref"`
38+}
39+
40+// HookReply is the one JSON line ermite answers. Message is shown to the
41+// pusher (on stderr of the hook, so git prefixes it with "remote:").
42+//
43+// {"ok":true} {"ok":false,"message":"push rejected: ..."}
44+type HookReply struct {
45+ OK bool `json:"ok"`
46+ Message string `json:"message,omitempty"`
47+}
48+
49+// refUpdates checks the request fields that do not need the token.
50+func (r HookRequest) refUpdates() ([]domain.RefUpdate, error) {
51+ switch r.Hook {
52+ case HookPreReceive, HookPostReceive:
53+ default:
54+ return nil, fmt.Errorf("unknown hook %q", r.Hook)
55+ }
56+ if r.Token == "" {
57+ return nil, errors.New("missing token")
58+ }
59+ out := make([]domain.RefUpdate, 0, len(r.Updates))
60+ for _, u := range r.Updates {
61+ up, err := domain.NewRefUpdate(u.Old, u.New, u.Ref)
62+ if err != nil {
63+ return nil, err
64+ }
65+ out = append(out, up)
66+ }
67+ return out, nil
68+}
69+
70+func encodeLine(v any) ([]byte, error) {
71+ b, err := json.Marshal(v)
72+ if err != nil {
73+ return nil, err
74+ }
75+ return append(b, '\n'), nil
76+}
A internal/adapters/githttp/scripts.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+// WriteHookScripts writes the pre-receive and post-receive scripts into dir
13+// (created if missing). Each is a /bin/sh script, mode 0755, that execs the
14+// ermite binary at exe with `hook <name>`; exe is quoted for the shell. The
15+// files are replaced atomically.
16+func WriteHookScripts(dir, exe string) error {
17+ if !filepath.IsAbs(exe) {
18+ return fmt.Errorf("hook executable %q is not an absolute path", exe)
19+ }
20+ if strings.ContainsRune(exe, 0) || strings.ContainsAny(exe, "\n\r") {
21+ return fmt.Errorf("hook executable path %q has control characters", exe)
22+ }
23+ if err := os.MkdirAll(dir, 0o700); err != nil {
24+ return fmt.Errorf("create hooks directory: %w", err)
25+ }
26+ for _, hook := range []string{HookPreReceive, HookPostReceive} {
27+ if err := writeExecutable(filepath.Join(dir, hook), hookScript(exe, hook)); err != nil {
28+ return fmt.Errorf("write %s hook: %w", hook, err)
29+ }
30+ }
31+ return nil
32+}
33+
34+// hookScript returns the text of a hook script.
35+func hookScript(exe, hook string) string {
36+ return "#!/bin/sh\n# Written by ermite at start. Do not edit.\nexec " + shellQuote(exe) + " hook " + hook + "\n"
37+}
38+
39+// shellQuote quotes s for /bin/sh with single quotes.
40+func shellQuote(s string) string {
41+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
42+}
43+
44+func writeExecutable(path, content string) error {
45+ tmp, err := os.CreateTemp(filepath.Dir(path), ".hook-*")
46+ if err != nil {
47+ return err
48+ }
49+ defer func() { _ = os.Remove(tmp.Name()) }() // no-op after the rename
50+ if _, err := tmp.WriteString(content); err != nil {
51+ _ = tmp.Close()
52+ return err
53+ }
54+ if err := tmp.Chmod(0o755); err != nil {
55+ _ = tmp.Close()
56+ return err
57+ }
58+ if err := tmp.Close(); err != nil {
59+ return err
60+ }
61+ return os.Rename(tmp.Name(), path)
62+}
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+// WriteHookScripts writes the pre-receive and post-receive scripts into dir
13+// (created if missing). Each is a /bin/sh script, mode 0755, that execs the
14+// ermite binary at exe with `hook <name>`; exe is quoted for the shell. The
15+// files are replaced atomically.
16+func WriteHookScripts(dir, exe string) error {
17+ if !filepath.IsAbs(exe) {
18+ return fmt.Errorf("hook executable %q is not an absolute path", exe)
19+ }
20+ if strings.ContainsRune(exe, 0) || strings.ContainsAny(exe, "\n\r") {
21+ return fmt.Errorf("hook executable path %q has control characters", exe)
22+ }
23+ if err := os.MkdirAll(dir, 0o700); err != nil {
24+ return fmt.Errorf("create hooks directory: %w", err)
25+ }
26+ for _, hook := range []string{HookPreReceive, HookPostReceive} {
27+ if err := writeExecutable(filepath.Join(dir, hook), hookScript(exe, hook)); err != nil {
28+ return fmt.Errorf("write %s hook: %w", hook, err)
29+ }
30+ }
31+ return nil
32+}
33+
34+// hookScript returns the text of a hook script.
35+func hookScript(exe, hook string) string {
36+ return "#!/bin/sh\n# Written by ermite at start. Do not edit.\nexec " + shellQuote(exe) + " hook " + hook + "\n"
37+}
38+
39+// shellQuote quotes s for /bin/sh with single quotes.
40+func shellQuote(s string) string {
41+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
42+}
43+
44+func writeExecutable(path, content string) error {
45+ tmp, err := os.CreateTemp(filepath.Dir(path), ".hook-*")
46+ if err != nil {
47+ return err
48+ }
49+ defer func() { _ = os.Remove(tmp.Name()) }() // no-op after the rename
50+ if _, err := tmp.WriteString(content); err != nil {
51+ _ = tmp.Close()
52+ return err
53+ }
54+ if err := tmp.Chmod(0o755); err != nil {
55+ _ = tmp.Close()
56+ return err
57+ }
58+ if err := tmp.Close(); err != nil {
59+ return err
60+ }
61+ return os.Rename(tmp.Name(), path)
62+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close