Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitaly.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatrick Steinhardt <psteinhardt@gitlab.com>2021-01-13 18:53:26 +0300
committerPatrick Steinhardt <psteinhardt@gitlab.com>2021-01-14 09:46:55 +0300
commite5c3c07f5d71da404813cc5b9db86fd75efbcc47 (patch)
treeac575cf7568a0bb8865b9d02d167203299cc13d1
parent8b2315f89d1094a21a387ff2170bef0b3c8056d3 (diff)
git: Rename files which implement commands
Given that our "SafeCmd" API has now been renamed to be a plain "Command" API, this commit renames the previous "command.go" file to "unsafe.go" and "safecmd.go" to "command.go". This reflects that we really only have one set of public APIs to spawn commands while there is also an unsafe internal implementation.
-rw-r--r--internal/git/command.go467
-rw-r--r--internal/git/command_test.go458
-rw-r--r--internal/git/safecmd.go469
-rw-r--r--internal/git/safecmd_test.go458
-rw-r--r--internal/git/unsafe.go94
-rw-r--r--internal/git/unsafe_test.go38
6 files changed, 992 insertions, 992 deletions
diff --git a/internal/git/command.go b/internal/git/command.go
index bc7d10227..18ee25eca 100644
--- a/internal/git/command.go
+++ b/internal/git/command.go
@@ -2,93 +2,468 @@ package git
import (
"context"
- "os/exec"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
- "gitlab.com/gitlab-org/gitaly/internal/cgroups"
+ "github.com/prometheus/client_golang/prometheus"
"gitlab.com/gitlab-org/gitaly/internal/command"
- "gitlab.com/gitlab-org/gitaly/internal/git/alternates"
"gitlab.com/gitlab-org/gitaly/internal/git/repository"
- "gitlab.com/gitlab-org/gitaly/internal/gitaly/config"
- "gitlab.com/gitlab-org/gitaly/internal/storage"
+ "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb"
)
-// CommandFactory knows how to properly construct different types of commands.
-type CommandFactory struct {
- locator storage.Locator
- cfg config.Cfg
- cgroupsManager cgroups.Manager
+var (
+ invalidationTotal = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "gitaly_invalid_commands_total",
+ Help: "Total number of invalid arguments tried to execute",
+ },
+ []string{"command"},
+ )
+
+ // ErrInvalidArg represent family of errors to report about bad argument used to make a call.
+ ErrInvalidArg = errors.New("invalid argument")
+)
+
+func init() {
+ prometheus.MustRegister(invalidationTotal)
}
-// NewCommandFactory returns a new instance of initialized CommandFactory.
-// Current implementation relies on the global var 'config.Config' and a single type of 'Locator' we currently have.
-// This dependency will be removed on the next iterations in scope of: https://gitlab.com/gitlab-org/gitaly/-/issues/2699
-func NewCommandFactory() *CommandFactory {
- return &CommandFactory{
- cfg: config.Config,
- locator: config.NewLocator(config.Config),
- cgroupsManager: cgroups.NewManager(config.Config.Cgroups),
- }
+func incrInvalidArg(subcmdName string) {
+ invalidationTotal.WithLabelValues(subcmdName).Inc()
+}
+
+// Cmd is an interface for safe git commands
+type Cmd interface {
+ CommandArgs() ([]string, error)
+ Subcommand() string
+}
+
+// SubCmd represents a specific git command
+type SubCmd struct {
+ Name string // e.g. "log", or "cat-file", or "worktree"
+ Flags []Option // optional flags before the positional args
+ Args []string // positional args after all flags
+ PostSepArgs []string // post separator (i.e. "--") positional args
}
-func (cf *CommandFactory) gitPath() string {
- return cf.cfg.Git.BinPath
+// cmdStream represents standard input/output streams for a command
+type cmdStream struct {
+ In io.Reader // standard input
+ Out, Err io.Writer // standard output and error
}
-// unsafeCmd creates a git.unsafeCmd with the given args, environment, and Repository
-func (cf *CommandFactory) unsafeCmd(ctx context.Context, extraEnv []string, stream cmdStream, repo repository.GitRepo, args ...string) (*command.Command, error) {
- args, env, err := cf.argsAndEnv(repo, args...)
+// Subcommand returns the subcommand name
+func (sc SubCmd) Subcommand() string { return sc.Name }
+
+// CommandArgs checks all arguments in the sub command and validates them
+func (sc SubCmd) CommandArgs() ([]string, error) {
+ var safeArgs []string
+
+ gitCommand, ok := gitCommands[sc.Name]
+ if !ok {
+ return nil, fmt.Errorf("invalid sub command name %q: %w", sc.Name, ErrInvalidArg)
+ }
+ safeArgs = append(safeArgs, sc.Name)
+
+ commandArgs, err := assembleCommandArgs(gitCommand, sc.Flags, sc.Args, sc.PostSepArgs)
if err != nil {
return nil, err
}
+ safeArgs = append(safeArgs, commandArgs...)
+
+ return safeArgs, nil
+}
+
+func assembleCommandArgs(gitCommand gitCommand, flags []Option, args []string, postSepArgs []string) ([]string, error) {
+ var commandArgs []string
+
+ for _, o := range flags {
+ args, err := o.OptionArgs()
+ if err != nil {
+ return nil, err
+ }
+ commandArgs = append(commandArgs, args...)
+ }
+
+ for _, a := range args {
+ if err := validatePositionalArg(a); err != nil {
+ return nil, err
+ }
+ commandArgs = append(commandArgs, a)
+ }
+
+ if gitCommand.supportsEndOfOptions() {
+ commandArgs = append(commandArgs, "--end-of-options")
+ }
+
+ if len(postSepArgs) > 0 {
+ commandArgs = append(commandArgs, "--")
+ }
+
+ // post separator args do not need any validation
+ commandArgs = append(commandArgs, postSepArgs...)
- env = append(env, extraEnv...)
+ return commandArgs, nil
+}
+
+// GlobalOption is an interface for all options which can be globally applied
+// to git commands. This is the command-inspecific part before the actual
+// command that's being run, e.g. the `-c` part in `git -c foo.bar=value
+// command`.
+type GlobalOption interface {
+ GlobalArgs() ([]string, error)
+}
+
+// Option is a git command line flag with validation logic
+type Option interface {
+ OptionArgs() ([]string, error)
+}
+
+// SubSubCmd is a positional argument that appears in the list of options for
+// a subcommand.
+type SubSubCmd struct {
+ // Name is the name of the subcommand, e.g. "remote" in `git remote set-url`
+ Name string
+ // Action is the action of the subcommand, e.g. "set-url" in `git remote set-url`
+ Action string
- return cf.unsafeBareCmd(ctx, stream, env, args...)
+ // Flags are optional flags before the positional args
+ Flags []Option
+ // Args are positional arguments after all flags
+ Args []string
+ // PostSepArgs are positional args after the "--" separator
+ PostSepArgs []string
}
-func (cf *CommandFactory) argsAndEnv(repo repository.GitRepo, args ...string) ([]string, []string, error) {
- repoPath, err := cf.locator.GetRepoPath(repo)
+func (sc SubSubCmd) Subcommand() string { return sc.Name }
+
+var actionRegex = regexp.MustCompile(`^[[:alnum:]]+[-[:alnum:]]*$`)
+
+func (sc SubSubCmd) CommandArgs() ([]string, error) {
+ var safeArgs []string
+
+ gitCommand, ok := gitCommands[sc.Name]
+ if !ok {
+ return nil, fmt.Errorf("invalid sub command name %q: %w", sc.Name, ErrInvalidArg)
+ }
+ safeArgs = append(safeArgs, sc.Name)
+
+ if !actionRegex.MatchString(sc.Action) {
+ return nil, fmt.Errorf("invalid sub command action %q: %w", sc.Action, ErrInvalidArg)
+ }
+ safeArgs = append(safeArgs, sc.Action)
+
+ commandArgs, err := assembleCommandArgs(gitCommand, sc.Flags, sc.Args, sc.PostSepArgs)
if err != nil {
- return nil, nil, err
+ return nil, err
}
+ safeArgs = append(safeArgs, commandArgs...)
+
+ return safeArgs, nil
+}
- env := alternates.Env(repoPath, repo.GetGitObjectDirectory(), repo.GetGitAlternateObjectDirectories())
- args = append([]string{"--git-dir", repoPath}, args...)
+// ConfigPair is a sub-command option for use with commands like "git config"
+type ConfigPair struct {
+ Key string
+ Value string
+ // Origin shows the origin type: file, standard input, blob, command line.
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt---show-origin
+ Origin string
+ // Scope shows the scope of this config value: local, global, system, command.
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt---show-scope
+ Scope string
+}
+
+var (
+ configKeyOptionRegex = regexp.MustCompile(`^[[:alnum:]]+[-[:alnum:]]*\.(.+\.)*[[:alnum:]]+[-[:alnum:]]*$`)
+ // configKeyGlobalRegex is intended to verify config keys when used as
+ // global arguments. We're playing it safe here by disallowing lots of
+ // keys which git would parse just fine, but we only have a limited
+ // number of config entries anyway. Most importantly, we cannot allow
+ // `=` as part of the key as that would break parsing of `git -c`.
+ configKeyGlobalRegex = regexp.MustCompile(`^[[:alnum:]]+(\.[-/_a-zA-Z0-9]+)+$`)
+)
+
+// OptionArgs validates the config pair args
+func (cp ConfigPair) OptionArgs() ([]string, error) {
+ if !configKeyOptionRegex.MatchString(cp.Key) {
+ return nil, fmt.Errorf("config key %q failed regexp validation: %w", cp.Key, ErrInvalidArg)
+ }
+ return []string{cp.Key, cp.Value}, nil
+}
- return args, env, nil
+// GlobalArgs generates a git `-c <key>=<value>` flag. The key must pass
+// validation by containing only alphanumeric sections separated by dots.
+// No other characters are allowed for now as `git -c` may not correctly parse
+// them, most importantly when they contain equals signs.
+func (cp ConfigPair) GlobalArgs() ([]string, error) {
+ if !configKeyGlobalRegex.MatchString(cp.Key) {
+ return nil, fmt.Errorf("config key %q failed regexp validation: %w", cp.Key, ErrInvalidArg)
+ }
+ return []string{"-c", fmt.Sprintf("%s=%s", cp.Key, cp.Value)}, nil
}
-// unsafeBareCmd creates a git.Command with the given args, stdin/stdout/stderr, and env
-func (cf *CommandFactory) unsafeBareCmd(ctx context.Context, stream cmdStream, env []string, args ...string) (*command.Command, error) {
- env = append(env, command.GitEnv...)
+// Flag is a single token optional command line argument that enables or
+// disables functionality (e.g. "-L")
+type Flag struct {
+ Name string
+}
+
+func (f Flag) GlobalArgs() ([]string, error) {
+ return f.OptionArgs()
+}
+
+// OptionArgs returns an error if the flag is not sanitary
+func (f Flag) OptionArgs() ([]string, error) {
+ if !flagRegex.MatchString(f.Name) {
+ return nil, fmt.Errorf("flag %q failed regex validation: %w", f.Name, ErrInvalidArg)
+ }
+ return []string{f.Name}, nil
+}
+
+// ValueFlag is an optional command line argument that is comprised of pair of
+// tokens (e.g. "-n 50")
+type ValueFlag struct {
+ Name string
+ Value string
+}
+
+func (vf ValueFlag) GlobalArgs() ([]string, error) {
+ return vf.OptionArgs()
+}
+
+// OptionArgs returns an error if the flag is not sanitary
+func (vf ValueFlag) OptionArgs() ([]string, error) {
+ if !flagRegex.MatchString(vf.Name) {
+ return nil, fmt.Errorf("value flag %q failed regex validation: %w", vf.Name, ErrInvalidArg)
+ }
+ return []string{vf.Name, vf.Value}, nil
+}
+
+var flagRegex = regexp.MustCompile(`^(-|--)[[:alnum:]]`)
+
+// IsInvalidArgErr relays if the error is due to an argument validation failure
+func IsInvalidArgErr(err error) bool {
+ return errors.Is(err, ErrInvalidArg)
+}
- cmd, err := command.New(ctx, exec.Command(cf.gitPath(), args...), stream.In, stream.Out, stream.Err, env...)
+func validatePositionalArg(arg string) error {
+ if strings.HasPrefix(arg, "-") {
+ return fmt.Errorf("positional arg %q cannot start with dash '-': %w", arg, ErrInvalidArg)
+ }
+ return nil
+}
+
+// ConvertGlobalOptions converts a protobuf message to command-line flags
+func ConvertGlobalOptions(options *gitalypb.GlobalOptions) []GlobalOption {
+ var globals []GlobalOption
+
+ if options == nil {
+ return globals
+ }
+
+ if options.GetLiteralPathspecs() {
+ globals = append(globals, Flag{"--literal-pathspecs"})
+ }
+
+ return globals
+}
+
+type cmdCfg struct {
+ env []string
+ globals []GlobalOption
+ stdin io.Reader
+ stdout io.Writer
+ stderr io.Writer
+ hooksConfigured bool
+}
+
+// CmdOpt is an option for running a command
+type CmdOpt func(*cmdCfg) error
+
+// WithStdin sets the command's stdin. Pass `command.SetupStdin` to make the
+// command suitable for `Write()`ing to.
+func WithStdin(r io.Reader) CmdOpt {
+ return func(c *cmdCfg) error {
+ c.stdin = r
+ return nil
+ }
+}
+
+// WithStdout sets the command's stdout.
+func WithStdout(w io.Writer) CmdOpt {
+ return func(c *cmdCfg) error {
+ c.stdout = w
+ return nil
+ }
+}
+
+// WithStderr sets the command's stderr.
+func WithStderr(w io.Writer) CmdOpt {
+ return func(c *cmdCfg) error {
+ c.stderr = w
+ return nil
+ }
+}
+
+// WithEnv adds environment variables to the command.
+func WithEnv(envs ...string) CmdOpt {
+ return func(c *cmdCfg) error {
+ c.env = append(c.env, envs...)
+ return nil
+ }
+}
+
+var (
+ // ErrRefHookRequired indicates a ref hook configuration is needed but
+ // absent from the command
+ ErrRefHookRequired = errors.New("ref hook is required but not configured")
+ // ErrRefHookNotRequired indicates an extraneous ref hook option was
+ // provided
+ ErrRefHookNotRequired = errors.New("ref hook is configured but not required")
+)
+
+func handleOpts(ctx context.Context, sc Cmd, cc *cmdCfg, opts []CmdOpt) error {
+ gitCommand, ok := gitCommands[sc.Subcommand()]
+ if !ok {
+ return fmt.Errorf("invalid sub command name %q: %w", sc.Subcommand(), ErrInvalidArg)
+ }
+
+ for _, opt := range opts {
+ if err := opt(cc); err != nil {
+ return err
+ }
+ }
+
+ if !cc.hooksConfigured && gitCommand.mayUpdateRef() {
+ return fmt.Errorf("subcommand %q: %w", sc.Subcommand(), ErrRefHookRequired)
+ }
+ if cc.hooksConfigured && !gitCommand.mayUpdateRef() {
+ return fmt.Errorf("subcommand %q: %w", sc.Subcommand(), ErrRefHookNotRequired)
+ }
+ if gitCommand.mayGeneratePackfiles() {
+ cc.globals = append(cc.globals, ConfigPair{
+ Key: "pack.windowMemory", Value: "100m",
+ })
+ }
+
+ return nil
+}
+
+// NewCommand creates a command.Command with the given args and Repository. It
+// validates the arguments in the command before executing.
+func NewCommand(ctx context.Context, repo repository.GitRepo, globals []GlobalOption, sc Cmd, opts ...CmdOpt) (*command.Command, error) {
+ cc := &cmdCfg{}
+
+ if err := handleOpts(ctx, sc, cc, opts); err != nil {
+ return nil, err
+ }
+
+ args, err := combineArgs(globals, sc, cc)
if err != nil {
return nil, err
}
- if err := cf.cgroupsManager.AddCommand(cmd); err != nil {
+ return NewCommandFactory().unsafeCmd(ctx, cc.env, cmdStream{
+ In: cc.stdin,
+ Out: cc.stdout,
+ Err: cc.stderr,
+ }, repo, args...)
+}
+
+// NewCommandWithoutRepo creates a command.Command with the given args. It is not
+// connected to any specific git repository. It should only be used for
+// commands which do not require a git repository or which accept a repository
+// path as parameter like e.g. git-upload-pack(1).
+func NewCommandWithoutRepo(ctx context.Context, globals []GlobalOption, sc Cmd, opts ...CmdOpt) (*command.Command, error) {
+ cc := &cmdCfg{}
+
+ if err := handleOpts(ctx, sc, cc, opts); err != nil {
return nil, err
}
- return cmd, err
+ args, err := combineArgs(globals, sc, cc)
+ if err != nil {
+ return nil, err
+ }
+
+ return NewCommandFactory().unsafeBareCmd(ctx, cmdStream{
+ In: cc.stdin,
+ Out: cc.stdout,
+ Err: cc.stderr,
+ }, cc.env, args...)
}
-// unsafeBareCmdInDir calls unsafeBareCmd in dir.
-func (cf *CommandFactory) unsafeBareCmdInDir(ctx context.Context, dir string, stream cmdStream, env []string, args ...string) (*command.Command, error) {
- env = append(env, command.GitEnv...)
+// NewCommandWithDir creates a new command.Command whose working directory is set
+// to dir. Arguments are validated before the command is being run. It is
+// invalid to use an empty directory.
+func NewCommandWithDir(ctx context.Context, dir string, globals []GlobalOption, sc Cmd, opts ...CmdOpt) (*command.Command, error) {
+ if dir == "" {
+ return nil, errors.New("no 'dir' provided")
+ }
+
+ cc := &cmdCfg{}
- cmd1 := exec.Command(cf.gitPath(), args...)
- cmd1.Dir = dir
+ if err := handleOpts(ctx, sc, cc, opts); err != nil {
+ return nil, err
+ }
- cmd2, err := command.New(ctx, cmd1, stream.In, stream.Out, stream.Err, env...)
+ args, err := combineArgs(globals, sc, cc)
if err != nil {
return nil, err
}
- if err := cf.cgroupsManager.AddCommand(cmd2); err != nil {
+ return NewCommandFactory().unsafeBareCmdInDir(ctx, dir, cmdStream{
+ In: cc.stdin,
+ Out: cc.stdout,
+ Err: cc.stderr,
+ }, cc.env, args...)
+}
+
+func combineArgs(globals []GlobalOption, sc Cmd, cc *cmdCfg) (_ []string, err error) {
+ var args []string
+
+ defer func() {
+ if err != nil && IsInvalidArgErr(err) && len(args) > 0 {
+ incrInvalidArg(args[0])
+ }
+ }()
+
+ gitCommand, ok := gitCommands[sc.Subcommand()]
+ if !ok {
+ return nil, fmt.Errorf("invalid sub command name %q: %w", sc.Subcommand(), ErrInvalidArg)
+ }
+
+ // As global options may cancel out each other, we have a clearly
+ // defined order in which globals get applied. The order is similar to
+ // how git handles configuration options from most general to most
+ // specific. This allows callsites to override options which would
+ // otherwise be set up automatically.
+ //
+ // 1. Globals which get set up by default for a given git command.
+ // 2. Globals passed via command options, e.g. as set up by
+ // `WithReftxHook()`.
+ // 3. Globals passed directly to the command at the site of execution.
+ var combinedGlobals []GlobalOption
+ combinedGlobals = append(combinedGlobals, gitCommand.opts...)
+ combinedGlobals = append(combinedGlobals, cc.globals...)
+ combinedGlobals = append(combinedGlobals, globals...)
+
+ for _, global := range combinedGlobals {
+ globalArgs, err := global.GlobalArgs()
+ if err != nil {
+ return nil, err
+ }
+ args = append(args, globalArgs...)
+ }
+
+ scArgs, err := sc.CommandArgs()
+ if err != nil {
return nil, err
}
- return cmd2, nil
+ return append(args, scArgs...), nil
}
diff --git a/internal/git/command_test.go b/internal/git/command_test.go
index 55032a8de..ba70b677c 100644
--- a/internal/git/command_test.go
+++ b/internal/git/command_test.go
@@ -1,38 +1,458 @@
package git
import (
- "net/http"
- "net/http/httptest"
- "os"
+ "bytes"
+ "context"
+ "io/ioutil"
"testing"
"github.com/stretchr/testify/require"
+ "gitlab.com/gitlab-org/gitaly/internal/git/hooks"
+ "gitlab.com/gitlab-org/gitaly/internal/gitaly/config"
+ "gitlab.com/gitlab-org/gitaly/internal/helper/text"
"gitlab.com/gitlab-org/gitaly/internal/testhelper"
+ "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb"
)
-func TestGitCommandProxy(t *testing.T) {
- requestReceived := false
+func TestFlagValidation(t *testing.T) {
+ for _, tt := range []struct {
+ option Option
+ valid bool
+ }{
+ // valid Flag inputs
+ {option: Flag{Name: "-k"}, valid: true},
+ {option: Flag{Name: "-K"}, valid: true},
+ {option: Flag{Name: "--asdf"}, valid: true},
+ {option: Flag{Name: "--asdf-qwer"}, valid: true},
+ {option: Flag{Name: "--asdf=qwerty"}, valid: true},
+ {option: Flag{Name: "-D=A"}, valid: true},
+ {option: Flag{Name: "-D="}, valid: true},
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requestReceived = true
- }))
- defer ts.Close()
+ // valid ValueFlag inputs
+ {option: ValueFlag{"-k", "adsf"}, valid: true},
+ {option: ValueFlag{"-k", "--anything"}, valid: true},
+ {option: ValueFlag{"-k", ""}, valid: true},
- oldHTTPProxy := os.Getenv("http_proxy")
- defer os.Setenv("http_proxy", oldHTTPProxy)
+ // valid ConfigPair inputs
+ {option: ConfigPair{Key: "a.b.c", Value: "d"}, valid: true},
+ {option: ConfigPair{Key: "core.sound", Value: "meow"}, valid: true},
+ {option: ConfigPair{Key: "asdf-qwer.1234-5678", Value: ""}, valid: true},
+ {option: ConfigPair{Key: "http.https://user@example.com/repo.git.user", Value: "kitty"}, valid: true},
- os.Setenv("http_proxy", ts.URL)
+ // invalid Flag inputs
+ {option: Flag{Name: "-*"}}, // invalid character
+ {option: Flag{Name: "a"}}, // missing dash
+ {option: Flag{Name: "[["}}, // suspicious characters
+ {option: Flag{Name: "||"}}, // suspicious characters
+ {option: Flag{Name: "asdf=qwerty"}}, // missing dash
+
+ // invalid ValueFlag inputs
+ {option: ValueFlag{"k", "asdf"}}, // missing dash
+
+ // invalid ConfigPair inputs
+ {option: ConfigPair{Key: "", Value: ""}}, // key cannot be empty
+ {option: ConfigPair{Key: " ", Value: ""}}, // key cannot be whitespace
+ {option: ConfigPair{Key: "asdf", Value: ""}}, // two components required
+ {option: ConfigPair{Key: "asdf.", Value: ""}}, // 2nd component must be non-empty
+ {option: ConfigPair{Key: "--asdf.asdf", Value: ""}}, // key cannot start with dash
+ {option: ConfigPair{Key: "as[[df.asdf", Value: ""}}, // 1st component cannot contain non-alphanumeric
+ {option: ConfigPair{Key: "asdf.as]]df", Value: ""}}, // 2nd component cannot contain non-alphanumeric
+ } {
+ args, err := tt.option.OptionArgs()
+ if tt.valid {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err,
+ "expected error, but args %v passed validation", args)
+ require.True(t, IsInvalidArgErr(err))
+ }
+ }
+}
+
+func TestGlobalOption(t *testing.T) {
+ for _, tc := range []struct {
+ desc string
+ option GlobalOption
+ valid bool
+ expected []string
+ }{
+ {
+ desc: "single-letter flag",
+ option: Flag{Name: "-k"},
+ valid: true,
+ expected: []string{"-k"},
+ },
+ {
+ desc: "long option flag",
+ option: Flag{Name: "--asdf"},
+ valid: true,
+ expected: []string{"--asdf"},
+ },
+ {
+ desc: "multiple single-letter flags",
+ option: Flag{Name: "-abc"},
+ valid: true,
+ expected: []string{"-abc"},
+ },
+ {
+ desc: "single-letter option with value",
+ option: Flag{Name: "-a=value"},
+ valid: true,
+ expected: []string{"-a=value"},
+ },
+ {
+ desc: "long option with value",
+ option: Flag{Name: "--asdf=value"},
+ valid: true,
+ expected: []string{"--asdf=value"},
+ },
+ {
+ desc: "flags without dashes are not allowed",
+ option: Flag{Name: "foo"},
+ valid: false,
+ },
+ {
+ desc: "leading spaces are not allowed",
+ option: Flag{Name: " -a"},
+ valid: false,
+ },
+
+ {
+ desc: "single-letter value flag",
+ option: ValueFlag{Name: "-a", Value: "value"},
+ valid: true,
+ expected: []string{"-a", "value"},
+ },
+ {
+ desc: "long option value flag",
+ option: ValueFlag{Name: "--foobar", Value: "value"},
+ valid: true,
+ expected: []string{"--foobar", "value"},
+ },
+ {
+ desc: "multiple single-letters for value flag",
+ option: ValueFlag{Name: "-abc", Value: "value"},
+ valid: true,
+ expected: []string{"-abc", "value"},
+ },
+ {
+ desc: "value flag with empty value",
+ option: ValueFlag{Name: "--key", Value: ""},
+ valid: true,
+ expected: []string{"--key", ""},
+ },
+ {
+ desc: "value flag without dashes are not allowed",
+ option: ValueFlag{Name: "foo", Value: "bar"},
+ valid: false,
+ },
+ {
+ desc: "value flag with empty key are not allowed",
+ option: ValueFlag{Name: "", Value: "bar"},
+ valid: false,
+ },
+
+ {
+ desc: "config pair with key and value",
+ option: ConfigPair{Key: "foo.bar", Value: "value"},
+ valid: true,
+ expected: []string{"-c", "foo.bar=value"},
+ },
+ {
+ desc: "config pair with subsection",
+ option: ConfigPair{Key: "foo.bar.baz", Value: "value"},
+ valid: true,
+ expected: []string{"-c", "foo.bar.baz=value"},
+ },
+ {
+ desc: "config pair without value",
+ option: ConfigPair{Key: "foo.bar"},
+ valid: true,
+ expected: []string{"-c", "foo.bar="},
+ },
+ {
+ desc: "config pair with invalid section format",
+ option: ConfigPair{Key: "foo", Value: "value"},
+ valid: false,
+ },
+ {
+ desc: "config pair with leading whitespace",
+ option: ConfigPair{Key: " foo.bar", Value: "value"},
+ valid: false,
+ },
+ {
+ desc: "config pair with disallowed character in key",
+ option: ConfigPair{Key: "http.https://weak.example.com.sslVerify", Value: "false"},
+ valid: false,
+ },
+ } {
+ t.Run(tc.desc, func(t *testing.T) {
+ args, err := tc.option.GlobalArgs()
+ if tc.valid {
+ require.NoError(t, err)
+ require.Equal(t, tc.expected, args)
+ } else {
+ require.Error(t, err, "expected error, but args %v passed validation", args)
+ require.True(t, IsInvalidArgErr(err))
+ }
+ })
+ }
+}
+
+func TestNewCommandInvalidArg(t *testing.T) {
+ for _, tt := range []struct {
+ globals []GlobalOption
+ subCmd Cmd
+ errMsg string
+ }{
+ {
+ subCmd: SubCmd{Name: "--meow"},
+ errMsg: `invalid sub command name "--meow": invalid argument`,
+ },
+ {
+ subCmd: SubCmd{
+ Name: "update-ref",
+ Flags: []Option{Flag{Name: "woof"}},
+ },
+ errMsg: `flag "woof" failed regex validation: invalid argument`,
+ },
+ {
+ subCmd: SubCmd{
+ Name: "update-ref",
+ Args: []string{"--tweet"},
+ },
+ errMsg: `positional arg "--tweet" cannot start with dash '-': invalid argument`,
+ },
+ {
+ subCmd: SubSubCmd{
+ Name: "update-ref",
+ Action: "-invalid",
+ },
+ errMsg: `invalid sub command action "-invalid": invalid argument`,
+ },
+ } {
+ _, err := NewCommand(
+ context.Background(),
+ &gitalypb.Repository{},
+ tt.globals,
+ tt.subCmd,
+ WithRefTxHook(context.Background(), &gitalypb.Repository{}, config.Config),
+ )
+ require.EqualError(t, err, tt.errMsg)
+ require.True(t, IsInvalidArgErr(err))
+ }
+}
+
+func TestNewCommandValid(t *testing.T) {
+ testRepo, testRepoPath, cleanup := testhelper.NewTestRepo(t)
+ defer cleanup()
ctx, cancel := testhelper.Context()
defer cancel()
- dir, cleanup := testhelper.TempDir(t)
- defer cleanup()
+ reenableGitCmd := disableGitCmd()
+ defer reenableGitCmd()
+
+ hooksPath := "core.hooksPath=" + hooks.Path(config.Config)
+
+ endOfOptions := "--end-of-options"
+
+ for _, tt := range []struct {
+ desc string
+ globals []GlobalOption
+ subCmd Cmd
+ expectArgs []string
+ }{
+ {
+ desc: "no args",
+ subCmd: SubCmd{Name: "update-ref"},
+ expectArgs: []string{"-c", hooksPath, "update-ref", endOfOptions},
+ },
+ {
+ desc: "single option",
+ globals: []GlobalOption{
+ Flag{Name: "--aaaa-bbbb"},
+ },
+ subCmd: SubCmd{Name: "update-ref"},
+ expectArgs: []string{"-c", hooksPath, "--aaaa-bbbb", "update-ref", endOfOptions},
+ },
+ {
+ desc: "empty arg and postsep args",
+ subCmd: SubCmd{
+ Name: "update-ref",
+ Args: []string{""},
+ PostSepArgs: []string{"-woof", ""},
+ },
+ expectArgs: []string{"-c", hooksPath, "update-ref", "", endOfOptions, "--", "-woof", ""},
+ },
+ {
+ desc: "full blown",
+ globals: []GlobalOption{
+ Flag{Name: "-a"},
+ ValueFlag{"-b", "c"},
+ },
+ subCmd: SubCmd{
+ Name: "update-ref",
+ Flags: []Option{
+ Flag{Name: "-e"},
+ ValueFlag{"-f", "g"},
+ Flag{Name: "-h=i"},
+ },
+ Args: []string{"1", "2"},
+ PostSepArgs: []string{"3", "4", "5"},
+ },
+ expectArgs: []string{"-c", hooksPath, "-a", "-b", "c", "update-ref", "-e", "-f", "g", "-h=i", "1", "2", endOfOptions, "--", "3", "4", "5"},
+ },
+ {
+ desc: "output to stdout",
+ subCmd: SubSubCmd{
+ Name: "update-ref",
+ Action: "verb",
+ Flags: []Option{
+ OutputToStdout,
+ Flag{Name: "--adjective"},
+ },
+ },
+ expectArgs: []string{"-c", hooksPath, "update-ref", "verb", "-", "--adjective", endOfOptions},
+ },
+ {
+ desc: "multiple value flags",
+ globals: []GlobalOption{
+ Flag{Name: "--contributing"},
+ ValueFlag{"--author", "a-gopher"},
+ },
+ subCmd: SubCmd{
+ Name: "update-ref",
+ Args: []string{"mr"},
+ Flags: []Option{
+ Flag{Name: "--is-important"},
+ ValueFlag{"--why", "looking-for-first-contribution"},
+ },
+ },
+ expectArgs: []string{"-c", hooksPath, "--contributing", "--author", "a-gopher", "update-ref", "--is-important", "--why", "looking-for-first-contribution", "mr", endOfOptions},
+ },
+ } {
+ t.Run(tt.desc, func(t *testing.T) {
+ opts := []CmdOpt{WithRefTxHook(ctx, &gitalypb.Repository{}, config.Config)}
+
+ cmd, err := NewCommand(ctx, testRepo, tt.globals, tt.subCmd, opts...)
+ require.NoError(t, err)
+ // ignore first 3 indeterministic args (executable path and repo args)
+ require.Equal(t, tt.expectArgs, cmd.Args()[3:])
+
+ cmd, err = NewCommandWithoutRepo(ctx, tt.globals, tt.subCmd, opts...)
+ require.NoError(t, err)
+ // ignore first indeterministic arg (executable path)
+ require.Equal(t, tt.expectArgs, cmd.Args()[1:])
+
+ cmd, err = NewCommandWithDir(ctx, testRepoPath, tt.globals, tt.subCmd, opts...)
+ require.NoError(t, err)
+ require.Equal(t, tt.expectArgs, cmd.Args()[1:])
+ })
+ }
+}
+
+func disableGitCmd() testhelper.Cleanup {
+ oldBinPath := config.Config.Git.BinPath
+ config.Config.Git.BinPath = "/bin/echo"
+ return func() { config.Config.Git.BinPath = oldBinPath }
+}
+
+func TestNewCommandWithDir(t *testing.T) {
+ t.Run("no dir specified", func(t *testing.T) {
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ _, err := NewCommandWithDir(ctx, "", nil, nil, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "no 'dir' provided")
+ })
+
+ t.Run("runs in dir", func(t *testing.T) {
+ _, repoPath, cleanup := testhelper.NewTestRepoWithWorktree(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ var stderr bytes.Buffer
+ cmd, err := NewCommandWithDir(ctx, repoPath, nil, SubCmd{
+ Name: "rev-parse",
+ Args: []string{"master"},
+ }, WithStderr(&stderr))
+ require.NoError(t, err)
+
+ revData, err := ioutil.ReadAll(cmd)
+ require.NoError(t, err)
+
+ require.NoError(t, cmd.Wait(), stderr.String())
+
+ require.Equal(t, "1e292f8fedd741b75372e19097c76d327140c312", text.ChompBytes(revData))
+ })
+
+ t.Run("doesn't runs in non existing dir", func(t *testing.T) {
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ var stderr bytes.Buffer
+ _, err := NewCommandWithDir(ctx, "non-existing-dir", nil, SubCmd{
+ Name: "rev-parse",
+ Args: []string{"master"},
+ }, WithStderr(&stderr))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "no such file or directory")
+ })
+}
+
+func TestNewCommand(t *testing.T) {
+ t.Run("stderr is empty if no error", func(t *testing.T) {
+ repo, _, cleanup := testhelper.NewTestRepoWithWorktree(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ var stderr bytes.Buffer
+ cmd, err := NewCommand(ctx, repo, nil,
+ SubCmd{
+ Name: "rev-parse",
+ Args: []string{"master"},
+ },
+ WithStderr(&stderr),
+ )
+ require.NoError(t, err)
+
+ revData, err := ioutil.ReadAll(cmd)
+ require.NoError(t, err)
+
+ require.NoError(t, cmd.Wait(), stderr.String())
+
+ require.Equal(t, "1e292f8fedd741b75372e19097c76d327140c312", text.ChompBytes(revData))
+ require.Empty(t, stderr.String())
+ })
+
+ t.Run("stderr contains error message on failure", func(t *testing.T) {
+ repo, _, cleanup := testhelper.NewTestRepoWithWorktree(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ var stderr bytes.Buffer
+ cmd, err := NewCommand(ctx, repo, nil, SubCmd{
+ Name: "rev-parse",
+ Args: []string{"invalid-ref"},
+ },
+ WithStderr(&stderr),
+ )
+ require.NoError(t, err)
+
+ revData, err := ioutil.ReadAll(cmd)
+ require.NoError(t, err)
- cmd, err := NewCommandFactory().unsafeBareCmd(ctx, cmdStream{}, nil, "clone", "http://gitlab.com/bogus-repo", dir)
- require.NoError(t, err)
+ require.Error(t, cmd.Wait())
- err = cmd.Wait()
- require.NoError(t, err)
- require.True(t, requestReceived)
+ require.Equal(t, "invalid-ref", text.ChompBytes(revData))
+ require.Contains(t, stderr.String(), "fatal: ambiguous argument 'invalid-ref': unknown revision or path not in the working tree.")
+ })
}
diff --git a/internal/git/safecmd.go b/internal/git/safecmd.go
deleted file mode 100644
index 18ee25eca..000000000
--- a/internal/git/safecmd.go
+++ /dev/null
@@ -1,469 +0,0 @@
-package git
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "regexp"
- "strings"
-
- "github.com/prometheus/client_golang/prometheus"
- "gitlab.com/gitlab-org/gitaly/internal/command"
- "gitlab.com/gitlab-org/gitaly/internal/git/repository"
- "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb"
-)
-
-var (
- invalidationTotal = prometheus.NewCounterVec(
- prometheus.CounterOpts{
- Name: "gitaly_invalid_commands_total",
- Help: "Total number of invalid arguments tried to execute",
- },
- []string{"command"},
- )
-
- // ErrInvalidArg represent family of errors to report about bad argument used to make a call.
- ErrInvalidArg = errors.New("invalid argument")
-)
-
-func init() {
- prometheus.MustRegister(invalidationTotal)
-}
-
-func incrInvalidArg(subcmdName string) {
- invalidationTotal.WithLabelValues(subcmdName).Inc()
-}
-
-// Cmd is an interface for safe git commands
-type Cmd interface {
- CommandArgs() ([]string, error)
- Subcommand() string
-}
-
-// SubCmd represents a specific git command
-type SubCmd struct {
- Name string // e.g. "log", or "cat-file", or "worktree"
- Flags []Option // optional flags before the positional args
- Args []string // positional args after all flags
- PostSepArgs []string // post separator (i.e. "--") positional args
-}
-
-// cmdStream represents standard input/output streams for a command
-type cmdStream struct {
- In io.Reader // standard input
- Out, Err io.Writer // standard output and error
-}
-
-// Subcommand returns the subcommand name
-func (sc SubCmd) Subcommand() string { return sc.Name }
-
-// CommandArgs checks all arguments in the sub command and validates them
-func (sc SubCmd) CommandArgs() ([]string, error) {
- var safeArgs []string
-
- gitCommand, ok := gitCommands[sc.Name]
- if !ok {
- return nil, fmt.Errorf("invalid sub command name %q: %w", sc.Name, ErrInvalidArg)
- }
- safeArgs = append(safeArgs, sc.Name)
-
- commandArgs, err := assembleCommandArgs(gitCommand, sc.Flags, sc.Args, sc.PostSepArgs)
- if err != nil {
- return nil, err
- }
- safeArgs = append(safeArgs, commandArgs...)
-
- return safeArgs, nil
-}
-
-func assembleCommandArgs(gitCommand gitCommand, flags []Option, args []string, postSepArgs []string) ([]string, error) {
- var commandArgs []string
-
- for _, o := range flags {
- args, err := o.OptionArgs()
- if err != nil {
- return nil, err
- }
- commandArgs = append(commandArgs, args...)
- }
-
- for _, a := range args {
- if err := validatePositionalArg(a); err != nil {
- return nil, err
- }
- commandArgs = append(commandArgs, a)
- }
-
- if gitCommand.supportsEndOfOptions() {
- commandArgs = append(commandArgs, "--end-of-options")
- }
-
- if len(postSepArgs) > 0 {
- commandArgs = append(commandArgs, "--")
- }
-
- // post separator args do not need any validation
- commandArgs = append(commandArgs, postSepArgs...)
-
- return commandArgs, nil
-}
-
-// GlobalOption is an interface for all options which can be globally applied
-// to git commands. This is the command-inspecific part before the actual
-// command that's being run, e.g. the `-c` part in `git -c foo.bar=value
-// command`.
-type GlobalOption interface {
- GlobalArgs() ([]string, error)
-}
-
-// Option is a git command line flag with validation logic
-type Option interface {
- OptionArgs() ([]string, error)
-}
-
-// SubSubCmd is a positional argument that appears in the list of options for
-// a subcommand.
-type SubSubCmd struct {
- // Name is the name of the subcommand, e.g. "remote" in `git remote set-url`
- Name string
- // Action is the action of the subcommand, e.g. "set-url" in `git remote set-url`
- Action string
-
- // Flags are optional flags before the positional args
- Flags []Option
- // Args are positional arguments after all flags
- Args []string
- // PostSepArgs are positional args after the "--" separator
- PostSepArgs []string
-}
-
-func (sc SubSubCmd) Subcommand() string { return sc.Name }
-
-var actionRegex = regexp.MustCompile(`^[[:alnum:]]+[-[:alnum:]]*$`)
-
-func (sc SubSubCmd) CommandArgs() ([]string, error) {
- var safeArgs []string
-
- gitCommand, ok := gitCommands[sc.Name]
- if !ok {
- return nil, fmt.Errorf("invalid sub command name %q: %w", sc.Name, ErrInvalidArg)
- }
- safeArgs = append(safeArgs, sc.Name)
-
- if !actionRegex.MatchString(sc.Action) {
- return nil, fmt.Errorf("invalid sub command action %q: %w", sc.Action, ErrInvalidArg)
- }
- safeArgs = append(safeArgs, sc.Action)
-
- commandArgs, err := assembleCommandArgs(gitCommand, sc.Flags, sc.Args, sc.PostSepArgs)
- if err != nil {
- return nil, err
- }
- safeArgs = append(safeArgs, commandArgs...)
-
- return safeArgs, nil
-}
-
-// ConfigPair is a sub-command option for use with commands like "git config"
-type ConfigPair struct {
- Key string
- Value string
- // Origin shows the origin type: file, standard input, blob, command line.
- // https://git-scm.com/docs/git-config#Documentation/git-config.txt---show-origin
- Origin string
- // Scope shows the scope of this config value: local, global, system, command.
- // https://git-scm.com/docs/git-config#Documentation/git-config.txt---show-scope
- Scope string
-}
-
-var (
- configKeyOptionRegex = regexp.MustCompile(`^[[:alnum:]]+[-[:alnum:]]*\.(.+\.)*[[:alnum:]]+[-[:alnum:]]*$`)
- // configKeyGlobalRegex is intended to verify config keys when used as
- // global arguments. We're playing it safe here by disallowing lots of
- // keys which git would parse just fine, but we only have a limited
- // number of config entries anyway. Most importantly, we cannot allow
- // `=` as part of the key as that would break parsing of `git -c`.
- configKeyGlobalRegex = regexp.MustCompile(`^[[:alnum:]]+(\.[-/_a-zA-Z0-9]+)+$`)
-)
-
-// OptionArgs validates the config pair args
-func (cp ConfigPair) OptionArgs() ([]string, error) {
- if !configKeyOptionRegex.MatchString(cp.Key) {
- return nil, fmt.Errorf("config key %q failed regexp validation: %w", cp.Key, ErrInvalidArg)
- }
- return []string{cp.Key, cp.Value}, nil
-}
-
-// GlobalArgs generates a git `-c <key>=<value>` flag. The key must pass
-// validation by containing only alphanumeric sections separated by dots.
-// No other characters are allowed for now as `git -c` may not correctly parse
-// them, most importantly when they contain equals signs.
-func (cp ConfigPair) GlobalArgs() ([]string, error) {
- if !configKeyGlobalRegex.MatchString(cp.Key) {
- return nil, fmt.Errorf("config key %q failed regexp validation: %w", cp.Key, ErrInvalidArg)
- }
- return []string{"-c", fmt.Sprintf("%s=%s", cp.Key, cp.Value)}, nil
-}
-
-// Flag is a single token optional command line argument that enables or
-// disables functionality (e.g. "-L")
-type Flag struct {
- Name string
-}
-
-func (f Flag) GlobalArgs() ([]string, error) {
- return f.OptionArgs()
-}
-
-// OptionArgs returns an error if the flag is not sanitary
-func (f Flag) OptionArgs() ([]string, error) {
- if !flagRegex.MatchString(f.Name) {
- return nil, fmt.Errorf("flag %q failed regex validation: %w", f.Name, ErrInvalidArg)
- }
- return []string{f.Name}, nil
-}
-
-// ValueFlag is an optional command line argument that is comprised of pair of
-// tokens (e.g. "-n 50")
-type ValueFlag struct {
- Name string
- Value string
-}
-
-func (vf ValueFlag) GlobalArgs() ([]string, error) {
- return vf.OptionArgs()
-}
-
-// OptionArgs returns an error if the flag is not sanitary
-func (vf ValueFlag) OptionArgs() ([]string, error) {
- if !flagRegex.MatchString(vf.Name) {
- return nil, fmt.Errorf("value flag %q failed regex validation: %w", vf.Name, ErrInvalidArg)
- }
- return []string{vf.Name, vf.Value}, nil
-}
-
-var flagRegex = regexp.MustCompile(`^(-|--)[[:alnum:]]`)
-
-// IsInvalidArgErr relays if the error is due to an argument validation failure
-func IsInvalidArgErr(err error) bool {
- return errors.Is(err, ErrInvalidArg)
-}
-
-func validatePositionalArg(arg string) error {
- if strings.HasPrefix(arg, "-") {
- return fmt.Errorf("positional arg %q cannot start with dash '-': %w", arg, ErrInvalidArg)
- }
- return nil
-}
-
-// ConvertGlobalOptions converts a protobuf message to command-line flags
-func ConvertGlobalOptions(options *gitalypb.GlobalOptions) []GlobalOption {
- var globals []GlobalOption
-
- if options == nil {
- return globals
- }
-
- if options.GetLiteralPathspecs() {
- globals = append(globals, Flag{"--literal-pathspecs"})
- }
-
- return globals
-}
-
-type cmdCfg struct {
- env []string
- globals []GlobalOption
- stdin io.Reader
- stdout io.Writer
- stderr io.Writer
- hooksConfigured bool
-}
-
-// CmdOpt is an option for running a command
-type CmdOpt func(*cmdCfg) error
-
-// WithStdin sets the command's stdin. Pass `command.SetupStdin` to make the
-// command suitable for `Write()`ing to.
-func WithStdin(r io.Reader) CmdOpt {
- return func(c *cmdCfg) error {
- c.stdin = r
- return nil
- }
-}
-
-// WithStdout sets the command's stdout.
-func WithStdout(w io.Writer) CmdOpt {
- return func(c *cmdCfg) error {
- c.stdout = w
- return nil
- }
-}
-
-// WithStderr sets the command's stderr.
-func WithStderr(w io.Writer) CmdOpt {
- return func(c *cmdCfg) error {
- c.stderr = w
- return nil
- }
-}
-
-// WithEnv adds environment variables to the command.
-func WithEnv(envs ...string) CmdOpt {
- return func(c *cmdCfg) error {
- c.env = append(c.env, envs...)
- return nil
- }
-}
-
-var (
- // ErrRefHookRequired indicates a ref hook configuration is needed but
- // absent from the command
- ErrRefHookRequired = errors.New("ref hook is required but not configured")
- // ErrRefHookNotRequired indicates an extraneous ref hook option was
- // provided
- ErrRefHookNotRequired = errors.New("ref hook is configured but not required")
-)
-
-func handleOpts(ctx context.Context, sc Cmd, cc *cmdCfg, opts []CmdOpt) error {
- gitCommand, ok := gitCommands[sc.Subcommand()]
- if !ok {
- return fmt.Errorf("invalid sub command name %q: %w", sc.Subcommand(), ErrInvalidArg)
- }
-
- for _, opt := range opts {
- if err := opt(cc); err != nil {
- return err
- }
- }
-
- if !cc.hooksConfigured && gitCommand.mayUpdateRef() {
- return fmt.Errorf("subcommand %q: %w", sc.Subcommand(), ErrRefHookRequired)
- }
- if cc.hooksConfigured && !gitCommand.mayUpdateRef() {
- return fmt.Errorf("subcommand %q: %w", sc.Subcommand(), ErrRefHookNotRequired)
- }
- if gitCommand.mayGeneratePackfiles() {
- cc.globals = append(cc.globals, ConfigPair{
- Key: "pack.windowMemory", Value: "100m",
- })
- }
-
- return nil
-}
-
-// NewCommand creates a command.Command with the given args and Repository. It
-// validates the arguments in the command before executing.
-func NewCommand(ctx context.Context, repo repository.GitRepo, globals []GlobalOption, sc Cmd, opts ...CmdOpt) (*command.Command, error) {
- cc := &cmdCfg{}
-
- if err := handleOpts(ctx, sc, cc, opts); err != nil {
- return nil, err
- }
-
- args, err := combineArgs(globals, sc, cc)
- if err != nil {
- return nil, err
- }
-
- return NewCommandFactory().unsafeCmd(ctx, cc.env, cmdStream{
- In: cc.stdin,
- Out: cc.stdout,
- Err: cc.stderr,
- }, repo, args...)
-}
-
-// NewCommandWithoutRepo creates a command.Command with the given args. It is not
-// connected to any specific git repository. It should only be used for
-// commands which do not require a git repository or which accept a repository
-// path as parameter like e.g. git-upload-pack(1).
-func NewCommandWithoutRepo(ctx context.Context, globals []GlobalOption, sc Cmd, opts ...CmdOpt) (*command.Command, error) {
- cc := &cmdCfg{}
-
- if err := handleOpts(ctx, sc, cc, opts); err != nil {
- return nil, err
- }
-
- args, err := combineArgs(globals, sc, cc)
- if err != nil {
- return nil, err
- }
-
- return NewCommandFactory().unsafeBareCmd(ctx, cmdStream{
- In: cc.stdin,
- Out: cc.stdout,
- Err: cc.stderr,
- }, cc.env, args...)
-}
-
-// NewCommandWithDir creates a new command.Command whose working directory is set
-// to dir. Arguments are validated before the command is being run. It is
-// invalid to use an empty directory.
-func NewCommandWithDir(ctx context.Context, dir string, globals []GlobalOption, sc Cmd, opts ...CmdOpt) (*command.Command, error) {
- if dir == "" {
- return nil, errors.New("no 'dir' provided")
- }
-
- cc := &cmdCfg{}
-
- if err := handleOpts(ctx, sc, cc, opts); err != nil {
- return nil, err
- }
-
- args, err := combineArgs(globals, sc, cc)
- if err != nil {
- return nil, err
- }
-
- return NewCommandFactory().unsafeBareCmdInDir(ctx, dir, cmdStream{
- In: cc.stdin,
- Out: cc.stdout,
- Err: cc.stderr,
- }, cc.env, args...)
-}
-
-func combineArgs(globals []GlobalOption, sc Cmd, cc *cmdCfg) (_ []string, err error) {
- var args []string
-
- defer func() {
- if err != nil && IsInvalidArgErr(err) && len(args) > 0 {
- incrInvalidArg(args[0])
- }
- }()
-
- gitCommand, ok := gitCommands[sc.Subcommand()]
- if !ok {
- return nil, fmt.Errorf("invalid sub command name %q: %w", sc.Subcommand(), ErrInvalidArg)
- }
-
- // As global options may cancel out each other, we have a clearly
- // defined order in which globals get applied. The order is similar to
- // how git handles configuration options from most general to most
- // specific. This allows callsites to override options which would
- // otherwise be set up automatically.
- //
- // 1. Globals which get set up by default for a given git command.
- // 2. Globals passed via command options, e.g. as set up by
- // `WithReftxHook()`.
- // 3. Globals passed directly to the command at the site of execution.
- var combinedGlobals []GlobalOption
- combinedGlobals = append(combinedGlobals, gitCommand.opts...)
- combinedGlobals = append(combinedGlobals, cc.globals...)
- combinedGlobals = append(combinedGlobals, globals...)
-
- for _, global := range combinedGlobals {
- globalArgs, err := global.GlobalArgs()
- if err != nil {
- return nil, err
- }
- args = append(args, globalArgs...)
- }
-
- scArgs, err := sc.CommandArgs()
- if err != nil {
- return nil, err
- }
-
- return append(args, scArgs...), nil
-}
diff --git a/internal/git/safecmd_test.go b/internal/git/safecmd_test.go
deleted file mode 100644
index ba70b677c..000000000
--- a/internal/git/safecmd_test.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package git
-
-import (
- "bytes"
- "context"
- "io/ioutil"
- "testing"
-
- "github.com/stretchr/testify/require"
- "gitlab.com/gitlab-org/gitaly/internal/git/hooks"
- "gitlab.com/gitlab-org/gitaly/internal/gitaly/config"
- "gitlab.com/gitlab-org/gitaly/internal/helper/text"
- "gitlab.com/gitlab-org/gitaly/internal/testhelper"
- "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb"
-)
-
-func TestFlagValidation(t *testing.T) {
- for _, tt := range []struct {
- option Option
- valid bool
- }{
- // valid Flag inputs
- {option: Flag{Name: "-k"}, valid: true},
- {option: Flag{Name: "-K"}, valid: true},
- {option: Flag{Name: "--asdf"}, valid: true},
- {option: Flag{Name: "--asdf-qwer"}, valid: true},
- {option: Flag{Name: "--asdf=qwerty"}, valid: true},
- {option: Flag{Name: "-D=A"}, valid: true},
- {option: Flag{Name: "-D="}, valid: true},
-
- // valid ValueFlag inputs
- {option: ValueFlag{"-k", "adsf"}, valid: true},
- {option: ValueFlag{"-k", "--anything"}, valid: true},
- {option: ValueFlag{"-k", ""}, valid: true},
-
- // valid ConfigPair inputs
- {option: ConfigPair{Key: "a.b.c", Value: "d"}, valid: true},
- {option: ConfigPair{Key: "core.sound", Value: "meow"}, valid: true},
- {option: ConfigPair{Key: "asdf-qwer.1234-5678", Value: ""}, valid: true},
- {option: ConfigPair{Key: "http.https://user@example.com/repo.git.user", Value: "kitty"}, valid: true},
-
- // invalid Flag inputs
- {option: Flag{Name: "-*"}}, // invalid character
- {option: Flag{Name: "a"}}, // missing dash
- {option: Flag{Name: "[["}}, // suspicious characters
- {option: Flag{Name: "||"}}, // suspicious characters
- {option: Flag{Name: "asdf=qwerty"}}, // missing dash
-
- // invalid ValueFlag inputs
- {option: ValueFlag{"k", "asdf"}}, // missing dash
-
- // invalid ConfigPair inputs
- {option: ConfigPair{Key: "", Value: ""}}, // key cannot be empty
- {option: ConfigPair{Key: " ", Value: ""}}, // key cannot be whitespace
- {option: ConfigPair{Key: "asdf", Value: ""}}, // two components required
- {option: ConfigPair{Key: "asdf.", Value: ""}}, // 2nd component must be non-empty
- {option: ConfigPair{Key: "--asdf.asdf", Value: ""}}, // key cannot start with dash
- {option: ConfigPair{Key: "as[[df.asdf", Value: ""}}, // 1st component cannot contain non-alphanumeric
- {option: ConfigPair{Key: "asdf.as]]df", Value: ""}}, // 2nd component cannot contain non-alphanumeric
- } {
- args, err := tt.option.OptionArgs()
- if tt.valid {
- require.NoError(t, err)
- } else {
- require.Error(t, err,
- "expected error, but args %v passed validation", args)
- require.True(t, IsInvalidArgErr(err))
- }
- }
-}
-
-func TestGlobalOption(t *testing.T) {
- for _, tc := range []struct {
- desc string
- option GlobalOption
- valid bool
- expected []string
- }{
- {
- desc: "single-letter flag",
- option: Flag{Name: "-k"},
- valid: true,
- expected: []string{"-k"},
- },
- {
- desc: "long option flag",
- option: Flag{Name: "--asdf"},
- valid: true,
- expected: []string{"--asdf"},
- },
- {
- desc: "multiple single-letter flags",
- option: Flag{Name: "-abc"},
- valid: true,
- expected: []string{"-abc"},
- },
- {
- desc: "single-letter option with value",
- option: Flag{Name: "-a=value"},
- valid: true,
- expected: []string{"-a=value"},
- },
- {
- desc: "long option with value",
- option: Flag{Name: "--asdf=value"},
- valid: true,
- expected: []string{"--asdf=value"},
- },
- {
- desc: "flags without dashes are not allowed",
- option: Flag{Name: "foo"},
- valid: false,
- },
- {
- desc: "leading spaces are not allowed",
- option: Flag{Name: " -a"},
- valid: false,
- },
-
- {
- desc: "single-letter value flag",
- option: ValueFlag{Name: "-a", Value: "value"},
- valid: true,
- expected: []string{"-a", "value"},
- },
- {
- desc: "long option value flag",
- option: ValueFlag{Name: "--foobar", Value: "value"},
- valid: true,
- expected: []string{"--foobar", "value"},
- },
- {
- desc: "multiple single-letters for value flag",
- option: ValueFlag{Name: "-abc", Value: "value"},
- valid: true,
- expected: []string{"-abc", "value"},
- },
- {
- desc: "value flag with empty value",
- option: ValueFlag{Name: "--key", Value: ""},
- valid: true,
- expected: []string{"--key", ""},
- },
- {
- desc: "value flag without dashes are not allowed",
- option: ValueFlag{Name: "foo", Value: "bar"},
- valid: false,
- },
- {
- desc: "value flag with empty key are not allowed",
- option: ValueFlag{Name: "", Value: "bar"},
- valid: false,
- },
-
- {
- desc: "config pair with key and value",
- option: ConfigPair{Key: "foo.bar", Value: "value"},
- valid: true,
- expected: []string{"-c", "foo.bar=value"},
- },
- {
- desc: "config pair with subsection",
- option: ConfigPair{Key: "foo.bar.baz", Value: "value"},
- valid: true,
- expected: []string{"-c", "foo.bar.baz=value"},
- },
- {
- desc: "config pair without value",
- option: ConfigPair{Key: "foo.bar"},
- valid: true,
- expected: []string{"-c", "foo.bar="},
- },
- {
- desc: "config pair with invalid section format",
- option: ConfigPair{Key: "foo", Value: "value"},
- valid: false,
- },
- {
- desc: "config pair with leading whitespace",
- option: ConfigPair{Key: " foo.bar", Value: "value"},
- valid: false,
- },
- {
- desc: "config pair with disallowed character in key",
- option: ConfigPair{Key: "http.https://weak.example.com.sslVerify", Value: "false"},
- valid: false,
- },
- } {
- t.Run(tc.desc, func(t *testing.T) {
- args, err := tc.option.GlobalArgs()
- if tc.valid {
- require.NoError(t, err)
- require.Equal(t, tc.expected, args)
- } else {
- require.Error(t, err, "expected error, but args %v passed validation", args)
- require.True(t, IsInvalidArgErr(err))
- }
- })
- }
-}
-
-func TestNewCommandInvalidArg(t *testing.T) {
- for _, tt := range []struct {
- globals []GlobalOption
- subCmd Cmd
- errMsg string
- }{
- {
- subCmd: SubCmd{Name: "--meow"},
- errMsg: `invalid sub command name "--meow": invalid argument`,
- },
- {
- subCmd: SubCmd{
- Name: "update-ref",
- Flags: []Option{Flag{Name: "woof"}},
- },
- errMsg: `flag "woof" failed regex validation: invalid argument`,
- },
- {
- subCmd: SubCmd{
- Name: "update-ref",
- Args: []string{"--tweet"},
- },
- errMsg: `positional arg "--tweet" cannot start with dash '-': invalid argument`,
- },
- {
- subCmd: SubSubCmd{
- Name: "update-ref",
- Action: "-invalid",
- },
- errMsg: `invalid sub command action "-invalid": invalid argument`,
- },
- } {
- _, err := NewCommand(
- context.Background(),
- &gitalypb.Repository{},
- tt.globals,
- tt.subCmd,
- WithRefTxHook(context.Background(), &gitalypb.Repository{}, config.Config),
- )
- require.EqualError(t, err, tt.errMsg)
- require.True(t, IsInvalidArgErr(err))
- }
-}
-
-func TestNewCommandValid(t *testing.T) {
- testRepo, testRepoPath, cleanup := testhelper.NewTestRepo(t)
- defer cleanup()
-
- ctx, cancel := testhelper.Context()
- defer cancel()
-
- reenableGitCmd := disableGitCmd()
- defer reenableGitCmd()
-
- hooksPath := "core.hooksPath=" + hooks.Path(config.Config)
-
- endOfOptions := "--end-of-options"
-
- for _, tt := range []struct {
- desc string
- globals []GlobalOption
- subCmd Cmd
- expectArgs []string
- }{
- {
- desc: "no args",
- subCmd: SubCmd{Name: "update-ref"},
- expectArgs: []string{"-c", hooksPath, "update-ref", endOfOptions},
- },
- {
- desc: "single option",
- globals: []GlobalOption{
- Flag{Name: "--aaaa-bbbb"},
- },
- subCmd: SubCmd{Name: "update-ref"},
- expectArgs: []string{"-c", hooksPath, "--aaaa-bbbb", "update-ref", endOfOptions},
- },
- {
- desc: "empty arg and postsep args",
- subCmd: SubCmd{
- Name: "update-ref",
- Args: []string{""},
- PostSepArgs: []string{"-woof", ""},
- },
- expectArgs: []string{"-c", hooksPath, "update-ref", "", endOfOptions, "--", "-woof", ""},
- },
- {
- desc: "full blown",
- globals: []GlobalOption{
- Flag{Name: "-a"},
- ValueFlag{"-b", "c"},
- },
- subCmd: SubCmd{
- Name: "update-ref",
- Flags: []Option{
- Flag{Name: "-e"},
- ValueFlag{"-f", "g"},
- Flag{Name: "-h=i"},
- },
- Args: []string{"1", "2"},
- PostSepArgs: []string{"3", "4", "5"},
- },
- expectArgs: []string{"-c", hooksPath, "-a", "-b", "c", "update-ref", "-e", "-f", "g", "-h=i", "1", "2", endOfOptions, "--", "3", "4", "5"},
- },
- {
- desc: "output to stdout",
- subCmd: SubSubCmd{
- Name: "update-ref",
- Action: "verb",
- Flags: []Option{
- OutputToStdout,
- Flag{Name: "--adjective"},
- },
- },
- expectArgs: []string{"-c", hooksPath, "update-ref", "verb", "-", "--adjective", endOfOptions},
- },
- {
- desc: "multiple value flags",
- globals: []GlobalOption{
- Flag{Name: "--contributing"},
- ValueFlag{"--author", "a-gopher"},
- },
- subCmd: SubCmd{
- Name: "update-ref",
- Args: []string{"mr"},
- Flags: []Option{
- Flag{Name: "--is-important"},
- ValueFlag{"--why", "looking-for-first-contribution"},
- },
- },
- expectArgs: []string{"-c", hooksPath, "--contributing", "--author", "a-gopher", "update-ref", "--is-important", "--why", "looking-for-first-contribution", "mr", endOfOptions},
- },
- } {
- t.Run(tt.desc, func(t *testing.T) {
- opts := []CmdOpt{WithRefTxHook(ctx, &gitalypb.Repository{}, config.Config)}
-
- cmd, err := NewCommand(ctx, testRepo, tt.globals, tt.subCmd, opts...)
- require.NoError(t, err)
- // ignore first 3 indeterministic args (executable path and repo args)
- require.Equal(t, tt.expectArgs, cmd.Args()[3:])
-
- cmd, err = NewCommandWithoutRepo(ctx, tt.globals, tt.subCmd, opts...)
- require.NoError(t, err)
- // ignore first indeterministic arg (executable path)
- require.Equal(t, tt.expectArgs, cmd.Args()[1:])
-
- cmd, err = NewCommandWithDir(ctx, testRepoPath, tt.globals, tt.subCmd, opts...)
- require.NoError(t, err)
- require.Equal(t, tt.expectArgs, cmd.Args()[1:])
- })
- }
-}
-
-func disableGitCmd() testhelper.Cleanup {
- oldBinPath := config.Config.Git.BinPath
- config.Config.Git.BinPath = "/bin/echo"
- return func() { config.Config.Git.BinPath = oldBinPath }
-}
-
-func TestNewCommandWithDir(t *testing.T) {
- t.Run("no dir specified", func(t *testing.T) {
- ctx, cancel := testhelper.Context()
- defer cancel()
-
- _, err := NewCommandWithDir(ctx, "", nil, nil, nil)
- require.Error(t, err)
- require.Contains(t, err.Error(), "no 'dir' provided")
- })
-
- t.Run("runs in dir", func(t *testing.T) {
- _, repoPath, cleanup := testhelper.NewTestRepoWithWorktree(t)
- defer cleanup()
-
- ctx, cancel := testhelper.Context()
- defer cancel()
-
- var stderr bytes.Buffer
- cmd, err := NewCommandWithDir(ctx, repoPath, nil, SubCmd{
- Name: "rev-parse",
- Args: []string{"master"},
- }, WithStderr(&stderr))
- require.NoError(t, err)
-
- revData, err := ioutil.ReadAll(cmd)
- require.NoError(t, err)
-
- require.NoError(t, cmd.Wait(), stderr.String())
-
- require.Equal(t, "1e292f8fedd741b75372e19097c76d327140c312", text.ChompBytes(revData))
- })
-
- t.Run("doesn't runs in non existing dir", func(t *testing.T) {
- ctx, cancel := testhelper.Context()
- defer cancel()
-
- var stderr bytes.Buffer
- _, err := NewCommandWithDir(ctx, "non-existing-dir", nil, SubCmd{
- Name: "rev-parse",
- Args: []string{"master"},
- }, WithStderr(&stderr))
- require.Error(t, err)
- require.Contains(t, err.Error(), "no such file or directory")
- })
-}
-
-func TestNewCommand(t *testing.T) {
- t.Run("stderr is empty if no error", func(t *testing.T) {
- repo, _, cleanup := testhelper.NewTestRepoWithWorktree(t)
- defer cleanup()
-
- ctx, cancel := testhelper.Context()
- defer cancel()
-
- var stderr bytes.Buffer
- cmd, err := NewCommand(ctx, repo, nil,
- SubCmd{
- Name: "rev-parse",
- Args: []string{"master"},
- },
- WithStderr(&stderr),
- )
- require.NoError(t, err)
-
- revData, err := ioutil.ReadAll(cmd)
- require.NoError(t, err)
-
- require.NoError(t, cmd.Wait(), stderr.String())
-
- require.Equal(t, "1e292f8fedd741b75372e19097c76d327140c312", text.ChompBytes(revData))
- require.Empty(t, stderr.String())
- })
-
- t.Run("stderr contains error message on failure", func(t *testing.T) {
- repo, _, cleanup := testhelper.NewTestRepoWithWorktree(t)
- defer cleanup()
-
- ctx, cancel := testhelper.Context()
- defer cancel()
-
- var stderr bytes.Buffer
- cmd, err := NewCommand(ctx, repo, nil, SubCmd{
- Name: "rev-parse",
- Args: []string{"invalid-ref"},
- },
- WithStderr(&stderr),
- )
- require.NoError(t, err)
-
- revData, err := ioutil.ReadAll(cmd)
- require.NoError(t, err)
-
- require.Error(t, cmd.Wait())
-
- require.Equal(t, "invalid-ref", text.ChompBytes(revData))
- require.Contains(t, stderr.String(), "fatal: ambiguous argument 'invalid-ref': unknown revision or path not in the working tree.")
- })
-}
diff --git a/internal/git/unsafe.go b/internal/git/unsafe.go
new file mode 100644
index 000000000..bc7d10227
--- /dev/null
+++ b/internal/git/unsafe.go
@@ -0,0 +1,94 @@
+package git
+
+import (
+ "context"
+ "os/exec"
+
+ "gitlab.com/gitlab-org/gitaly/internal/cgroups"
+ "gitlab.com/gitlab-org/gitaly/internal/command"
+ "gitlab.com/gitlab-org/gitaly/internal/git/alternates"
+ "gitlab.com/gitlab-org/gitaly/internal/git/repository"
+ "gitlab.com/gitlab-org/gitaly/internal/gitaly/config"
+ "gitlab.com/gitlab-org/gitaly/internal/storage"
+)
+
+// CommandFactory knows how to properly construct different types of commands.
+type CommandFactory struct {
+ locator storage.Locator
+ cfg config.Cfg
+ cgroupsManager cgroups.Manager
+}
+
+// NewCommandFactory returns a new instance of initialized CommandFactory.
+// Current implementation relies on the global var 'config.Config' and a single type of 'Locator' we currently have.
+// This dependency will be removed on the next iterations in scope of: https://gitlab.com/gitlab-org/gitaly/-/issues/2699
+func NewCommandFactory() *CommandFactory {
+ return &CommandFactory{
+ cfg: config.Config,
+ locator: config.NewLocator(config.Config),
+ cgroupsManager: cgroups.NewManager(config.Config.Cgroups),
+ }
+}
+
+func (cf *CommandFactory) gitPath() string {
+ return cf.cfg.Git.BinPath
+}
+
+// unsafeCmd creates a git.unsafeCmd with the given args, environment, and Repository
+func (cf *CommandFactory) unsafeCmd(ctx context.Context, extraEnv []string, stream cmdStream, repo repository.GitRepo, args ...string) (*command.Command, error) {
+ args, env, err := cf.argsAndEnv(repo, args...)
+ if err != nil {
+ return nil, err
+ }
+
+ env = append(env, extraEnv...)
+
+ return cf.unsafeBareCmd(ctx, stream, env, args...)
+}
+
+func (cf *CommandFactory) argsAndEnv(repo repository.GitRepo, args ...string) ([]string, []string, error) {
+ repoPath, err := cf.locator.GetRepoPath(repo)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ env := alternates.Env(repoPath, repo.GetGitObjectDirectory(), repo.GetGitAlternateObjectDirectories())
+ args = append([]string{"--git-dir", repoPath}, args...)
+
+ return args, env, nil
+}
+
+// unsafeBareCmd creates a git.Command with the given args, stdin/stdout/stderr, and env
+func (cf *CommandFactory) unsafeBareCmd(ctx context.Context, stream cmdStream, env []string, args ...string) (*command.Command, error) {
+ env = append(env, command.GitEnv...)
+
+ cmd, err := command.New(ctx, exec.Command(cf.gitPath(), args...), stream.In, stream.Out, stream.Err, env...)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := cf.cgroupsManager.AddCommand(cmd); err != nil {
+ return nil, err
+ }
+
+ return cmd, err
+}
+
+// unsafeBareCmdInDir calls unsafeBareCmd in dir.
+func (cf *CommandFactory) unsafeBareCmdInDir(ctx context.Context, dir string, stream cmdStream, env []string, args ...string) (*command.Command, error) {
+ env = append(env, command.GitEnv...)
+
+ cmd1 := exec.Command(cf.gitPath(), args...)
+ cmd1.Dir = dir
+
+ cmd2, err := command.New(ctx, cmd1, stream.In, stream.Out, stream.Err, env...)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := cf.cgroupsManager.AddCommand(cmd2); err != nil {
+ return nil, err
+ }
+
+ return cmd2, nil
+}
diff --git a/internal/git/unsafe_test.go b/internal/git/unsafe_test.go
new file mode 100644
index 000000000..55032a8de
--- /dev/null
+++ b/internal/git/unsafe_test.go
@@ -0,0 +1,38 @@
+package git
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "gitlab.com/gitlab-org/gitaly/internal/testhelper"
+)
+
+func TestGitCommandProxy(t *testing.T) {
+ requestReceived := false
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestReceived = true
+ }))
+ defer ts.Close()
+
+ oldHTTPProxy := os.Getenv("http_proxy")
+ defer os.Setenv("http_proxy", oldHTTPProxy)
+
+ os.Setenv("http_proxy", ts.URL)
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ dir, cleanup := testhelper.TempDir(t)
+ defer cleanup()
+
+ cmd, err := NewCommandFactory().unsafeBareCmd(ctx, cmdStream{}, nil, "clone", "http://gitlab.com/bogus-repo", dir)
+ require.NoError(t, err)
+
+ err = cmd.Wait()
+ require.NoError(t, err)
+ require.True(t, requestReceived)
+}