service.go

  1package git
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"os"
  9	"os/exec"
 10	"strings"
 11	"sync"
 12
 13	"github.com/charmbracelet/log/v2"
 14)
 15
 16// Service is a Git daemon service.
 17type Service string
 18
 19const (
 20	// UploadPackService is the upload-pack service.
 21	UploadPackService Service = "git-upload-pack"
 22	// UploadArchiveService is the upload-archive service.
 23	UploadArchiveService Service = "git-upload-archive"
 24	// ReceivePackService is the receive-pack service.
 25	ReceivePackService Service = "git-receive-pack"
 26	// LFSTransferService is the LFS transfer service.
 27	LFSTransferService Service = "git-lfs-transfer"
 28	// LFSAuthenticateService is the LFS authenticate service.
 29	LFSAuthenticateService = "git-lfs-authenticate"
 30)
 31
 32// String returns the string representation of the service.
 33func (s Service) String() string {
 34	return string(s)
 35}
 36
 37// Name returns the name of the service.
 38func (s Service) Name() string {
 39	return strings.TrimPrefix(s.String(), "git-")
 40}
 41
 42// Handler is the service handler.
 43func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
 44	switch s {
 45	case UploadPackService, UploadArchiveService, ReceivePackService:
 46		return gitServiceHandler(ctx, s, cmd)
 47	case LFSTransferService:
 48		return LFSTransfer(ctx, cmd)
 49	case LFSAuthenticateService:
 50		return LFSAuthenticate(ctx, cmd)
 51	default:
 52		return fmt.Errorf("unsupported service: %s", s)
 53	}
 54}
 55
 56// ServiceHandler is a git service command handler.
 57type ServiceHandler func(ctx context.Context, cmd ServiceCommand) error
 58
 59// gitServiceHandler is the default service handler using the git binary.
 60func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) error {
 61	cmd := exec.CommandContext(ctx, "git")
 62	cmd.Dir = scmd.Dir
 63	cmd.Args = append(cmd.Args, []string{
 64		// Enable partial clones
 65		"-c", "uploadpack.allowFilter=true",
 66		// Enable push options
 67		"-c", "receive.advertisePushOptions=true",
 68		// Disable LFS filters
 69		"-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=",
 70		svc.Name(),
 71	}...)
 72	if len(scmd.Args) > 0 {
 73		cmd.Args = append(cmd.Args, scmd.Args...)
 74	}
 75
 76	cmd.Args = append(cmd.Args, ".")
 77
 78	cmd.Env = os.Environ()
 79	if len(scmd.Env) > 0 {
 80		cmd.Env = append(cmd.Env, scmd.Env...)
 81	}
 82
 83	if scmd.CmdFunc != nil {
 84		scmd.CmdFunc(cmd)
 85	}
 86
 87	var (
 88		err    error
 89		stdin  io.WriteCloser
 90		stdout io.ReadCloser
 91		stderr io.ReadCloser
 92	)
 93
 94	if scmd.Stdin != nil {
 95		stdin, err = cmd.StdinPipe()
 96		if err != nil {
 97			return err //nolint:wrapcheck
 98		}
 99	}
100
101	if scmd.Stdout != nil {
102		stdout, err = cmd.StdoutPipe()
103		if err != nil {
104			return err //nolint:wrapcheck
105		}
106	}
107
108	if scmd.Stderr != nil {
109		stderr, err = cmd.StderrPipe()
110		if err != nil {
111			return err //nolint:wrapcheck
112		}
113	}
114
115	if err := cmd.Start(); err != nil {
116		if errors.Is(err, os.ErrNotExist) {
117			return ErrInvalidRepo
118		}
119		return err //nolint:wrapcheck
120	}
121
122	wg := &sync.WaitGroup{}
123
124	// stdin
125	if scmd.Stdin != nil {
126		go func() {
127			defer stdin.Close() //nolint: errcheck
128			if _, err := io.Copy(stdin, scmd.Stdin); err != nil {
129				log.Errorf("gitServiceHandler: failed to copy stdin: %v", err)
130			}
131		}()
132	}
133
134	// stdout
135	if scmd.Stdout != nil {
136		wg.Add(1)
137		go func() {
138			defer wg.Done()
139			if _, err := io.Copy(scmd.Stdout, stdout); err != nil {
140				log.Errorf("gitServiceHandler: failed to copy stdout: %v", err)
141			}
142		}()
143	}
144
145	// stderr
146	if scmd.Stderr != nil {
147		wg.Add(1)
148		go func() {
149			defer wg.Done()
150			if _, erro := io.Copy(scmd.Stderr, stderr); err != nil {
151				log.Errorf("gitServiceHandler: failed to copy stderr: %v", erro)
152			}
153		}()
154	}
155
156	// Ensure all the output is written before waiting for the command to
157	// finish.
158	// Stdin is handled by the client side.
159	wg.Wait()
160
161	err = cmd.Wait()
162	if err != nil && errors.Is(err, os.ErrNotExist) {
163		return ErrInvalidRepo
164	} else if err != nil {
165		var exitErr *exec.ExitError
166		if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
167			return fmt.Errorf("%s: %s", exitErr, exitErr.Stderr)
168		}
169
170		return err //nolint:wrapcheck
171	}
172
173	return nil
174}
175
176// ServiceCommand is used to run a git service command.
177type ServiceCommand struct {
178	Stdin  io.Reader
179	Stdout io.Writer
180	Stderr io.Writer
181	Dir    string
182	Env    []string
183	Args   []string
184
185	// Modifier functions
186	CmdFunc func(*exec.Cmd)
187}
188
189// UploadPack runs the git upload-pack protocol against the provided repo.
190func UploadPack(ctx context.Context, cmd ServiceCommand) error {
191	return gitServiceHandler(ctx, UploadPackService, cmd)
192}
193
194// UploadArchive runs the git upload-archive protocol against the provided repo.
195func UploadArchive(ctx context.Context, cmd ServiceCommand) error {
196	return gitServiceHandler(ctx, UploadArchiveService, cmd)
197}
198
199// ReceivePack runs the git receive-pack protocol against the provided repo.
200func ReceivePack(ctx context.Context, cmd ServiceCommand) error {
201	return gitServiceHandler(ctx, ReceivePackService, cmd)
202}