1package git
2
3import (
4	"fmt"
5	"io"
6	"regexp"
7	"strings"
8
9	"gitlab.com/gitlab-org/gitaly/v14/proto/go/gitalypb"
10)
11
12var (
13	configKeyOptionRegex = regexp.MustCompile(`^[[:alnum:]]+[-[:alnum:]]*\.(.+\.)*[[:alnum:]]+[-[:alnum:]]*$`)
14	// configKeyGlobalRegex is intended to verify config keys when used as
15	// global arguments. We're playing it safe here by disallowing lots of
16	// keys which git would parse just fine, but we only have a limited
17	// number of config entries anyway. Most importantly, we cannot allow
18	// `=` as part of the key as that would break parsing of `git -c`.
19	configKeyGlobalRegex = regexp.MustCompile(`^[[:alnum:]]+(\.[-/_a-zA-Z0-9]+)+$`)
20
21	flagRegex = regexp.MustCompile(`^(-|--)[[:alnum:]]`)
22)
23
24// GlobalOption is an interface for all options which can be globally applied
25// to git commands. This is the command-inspecific part before the actual
26// command that's being run, e.g. the `-c` part in `git -c foo.bar=value
27// command`.
28type GlobalOption interface {
29	GlobalArgs() ([]string, error)
30}
31
32// Option is a git command line flag with validation logic
33type Option interface {
34	OptionArgs() ([]string, error)
35}
36
37// ConfigPair is a sub-command option for use with commands like "git config"
38type ConfigPair struct {
39	Key   string
40	Value string
41	// Origin shows the origin type: file, standard input, blob, command line.
42	// https://git-scm.com/docs/git-config#Documentation/git-config.txt---show-origin
43	Origin string
44	// Scope shows the scope of this config value: local, global, system, command.
45	// https://git-scm.com/docs/git-config#Documentation/git-config.txt---show-scope
46	Scope string
47}
48
49// OptionArgs validates the config pair args
50func (cp ConfigPair) OptionArgs() ([]string, error) {
51	if !configKeyOptionRegex.MatchString(cp.Key) {
52		return nil, fmt.Errorf("config key %q failed regexp validation: %w", cp.Key, ErrInvalidArg)
53	}
54	return []string{cp.Key, cp.Value}, nil
55}
56
57// GlobalArgs generates a git `-c <key>=<value>` flag. The key must pass
58// validation by containing only alphanumeric sections separated by dots.
59// No other characters are allowed for now as `git -c` may not correctly parse
60// them, most importantly when they contain equals signs.
61func (cp ConfigPair) GlobalArgs() ([]string, error) {
62	if !configKeyGlobalRegex.MatchString(cp.Key) {
63		return nil, fmt.Errorf("config key %q failed regexp validation: %w", cp.Key, ErrInvalidArg)
64	}
65	return []string{"-c", fmt.Sprintf("%s=%s", cp.Key, cp.Value)}, nil
66}
67
68// Flag is a single token optional command line argument that enables or
69// disables functionality (e.g. "-L")
70type Flag struct {
71	Name string
72}
73
74// GlobalArgs returns the arguments for the given flag, which should typically
75// only be the flag itself. It returns an error if the flag is not sanitary.
76func (f Flag) GlobalArgs() ([]string, error) {
77	return f.OptionArgs()
78}
79
80// OptionArgs returns an error if the flag is not sanitary
81func (f Flag) OptionArgs() ([]string, error) {
82	if !flagRegex.MatchString(f.Name) {
83		return nil, fmt.Errorf("flag %q failed regex validation: %w", f.Name, ErrInvalidArg)
84	}
85	return []string{f.Name}, nil
86}
87
88// ValueFlag is an optional command line argument that is comprised of pair of
89// tokens (e.g. "-n 50")
90type ValueFlag struct {
91	Name  string
92	Value string
93}
94
95// GlobalArgs returns the arguments for the given value flag, which should
96// typically be two arguments: the flag and its value. It returns an error if the value flag is not sanitary.
97func (vf ValueFlag) GlobalArgs() ([]string, error) {
98	return vf.OptionArgs()
99}
100
101// OptionArgs returns an error if the flag is not sanitary
102func (vf ValueFlag) OptionArgs() ([]string, error) {
103	if !flagRegex.MatchString(vf.Name) {
104		return nil, fmt.Errorf("value flag %q failed regex validation: %w", vf.Name, ErrInvalidArg)
105	}
106	return []string{vf.Name, vf.Value}, nil
107}
108
109// ConvertGlobalOptions converts a protobuf message to a CmdOpt.
110func ConvertGlobalOptions(options *gitalypb.GlobalOptions) []CmdOpt {
111	if options != nil && options.GetLiteralPathspecs() {
112		return []CmdOpt{
113			WithEnv("GIT_LITERAL_PATHSPECS=1"),
114		}
115	}
116
117	return nil
118}
119
120// ConvertConfigOptions converts `<key>=<value>` config entries into `ConfigPairs`.
121func ConvertConfigOptions(options []string) ([]ConfigPair, error) {
122	configPairs := make([]ConfigPair, len(options))
123
124	for i, option := range options {
125		configPair := strings.SplitN(option, "=", 2)
126		if len(configPair) != 2 {
127			return nil, fmt.Errorf("cannot convert invalid config key: %q", option)
128		}
129
130		configPairs[i] = ConfigPair{Key: configPair[0], Value: configPair[1]}
131	}
132
133	return configPairs, nil
134}
135
136type cmdCfg struct {
137	env             []string
138	globals         []GlobalOption
139	stdin           io.Reader
140	stdout          io.Writer
141	stderr          io.Writer
142	hooksConfigured bool
143}
144
145// CmdOpt is an option for running a command
146type CmdOpt func(*cmdCfg) error
147
148// WithStdin sets the command's stdin. Pass `command.SetupStdin` to make the
149// command suitable for `Write()`ing to.
150func WithStdin(r io.Reader) CmdOpt {
151	return func(c *cmdCfg) error {
152		c.stdin = r
153		return nil
154	}
155}
156
157// WithStdout sets the command's stdout.
158func WithStdout(w io.Writer) CmdOpt {
159	return func(c *cmdCfg) error {
160		c.stdout = w
161		return nil
162	}
163}
164
165// WithStderr sets the command's stderr.
166func WithStderr(w io.Writer) CmdOpt {
167	return func(c *cmdCfg) error {
168		c.stderr = w
169		return nil
170	}
171}
172
173// WithEnv adds environment variables to the command.
174func WithEnv(envs ...string) CmdOpt {
175	return func(c *cmdCfg) error {
176		c.env = append(c.env, envs...)
177		return nil
178	}
179}
180
181// WithConfig adds git configuration entries to the command.
182func WithConfig(configPairs ...ConfigPair) CmdOpt {
183	return func(c *cmdCfg) error {
184		for _, configPair := range configPairs {
185			c.globals = append(c.globals, configPair)
186		}
187		return nil
188	}
189}
190
191// WithConfigEnv adds git configuration entries to the command's environment. This should be used
192// in place of `WithConfig()` in case config entries may contain secrets which shouldn't leak e.g.
193// via the process's command line.
194func WithConfigEnv(configPairs ...ConfigPair) CmdOpt {
195	return func(c *cmdCfg) error {
196		env := make([]string, 0, len(configPairs)*2+1)
197
198		for i, configPair := range configPairs {
199			env = append(env,
200				fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, configPair.Key),
201				fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, configPair.Value),
202			)
203		}
204		env = append(env, fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(configPairs)))
205
206		c.env = append(c.env, env...)
207		return nil
208	}
209}
210
211// WithGlobalOption adds the global options to the command. These are universal options which work
212// across all git commands.
213func WithGlobalOption(opts ...GlobalOption) CmdOpt {
214	return func(c *cmdCfg) error {
215		c.globals = append(c.globals, opts...)
216		return nil
217	}
218}
219