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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 18 | 18 | DisableDefaultCmd: true, | |
| 19 | 19 | }, | |
| 20 | 20 | } | |
| 21 | - | root.AddCommand(newServeCmd(), newVersionCmd()) | |
| 21 | + | root.AddCommand(newServeCmd(), newVersionCmd(), newHookCmd()) | |
| 22 | 22 | return root | |
| 23 | 23 | } | |
| 24 | 24 | ||
| @@ -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 root | 22 | 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 | + | } | |||