From 83b4dd21de2604e66a2df85e15075758cc8defee Mon Sep 17 00:00:00 2001 From: Tim Hockin Date: Wed, 10 Mar 2021 22:27:42 -0800 Subject: [PATCH] Add --git-config flag This allows arbitrary git configs to be passed in. For example: `git config --global http.postBuffer 1048576000` `git config --global http.sslCAInfo /path/to/cert/file` `git config --global http.sslVerify false` This flag takes a comma-separated list of `key:val` pairs. The key part is passed to `git config` and must be a valid gitconfig section header and variable name. The val part can be either a quoted or unquoted value. For all values the following escape sequences are supported: * `\n` => [newline] * `\t` => [tab] * `\"` => `"` * `\,` => `,` * `\\` => `\` Within unquoted values, commas MUST be escaped. Within quoted values, commas MAY be escaped, but are not required to be. Any other escape sequence is an error. Example: `--git-config=foo.one:val1,foo.two:"quoted val",foo.three:12345` This commit exposed a bug in runCommand() which modified its args when they had an embedded space. --- README.md | 1 + cmd/git-sync/main.go | 205 ++++++++++++++++++++++++++++++++++++-- cmd/git-sync/main_test.go | 89 +++++++++++++++++ test_e2e.sh | 21 ++++ 4 files changed, 305 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 41d635c63..b346b4249 100644 --- a/README.md +++ b/README.md @@ -115,5 +115,6 @@ docker run -d \ | GIT_SYNC_HTTP_BIND | `--http-bind` | the bind address (including port) for git-sync's HTTP endpoint | "" | | GIT_SYNC_HTTP_METRICS | `--http-metrics` | enable metrics on git-sync's HTTP endpoint | true | | GIT_SYNC_HTTP_PPROF | `--http-pprof` | enable the pprof debug endpoints on git-sync's HTTP endpoint | false | +| GIT_SYNC_GIT_CONFIG | `--git-config` | additional git config options in 'key1:val1,key2:val2' format | "" | [![Analytics](https://kubernetes-site.appspot.com/UA-36037335-10/GitHub/git-sync/README.md?pixel)]() diff --git a/cmd/git-sync/main.go b/cmd/git-sync/main.go index c0e51c875..c2b9d6ecd 100644 --- a/cmd/git-sync/main.go +++ b/cmd/git-sync/main.go @@ -115,6 +115,8 @@ var flAskPassURL = pflag.String("askpass-url", envString("GIT_ASKPASS_URL", ""), var flGitCmd = pflag.String("git", envString("GIT_SYNC_GIT", "git"), "the git command to run (subject to PATH search, mostly for testing)") +var flGitConfig = pflag.String("git-config", envString("GIT_SYNC_GIT_CONFIG", ""), + "additional git config options in 'key1:val1,key2:val2' format") var flHTTPBind = pflag.String("http-bind", envString("GIT_SYNC_HTTP_BIND", ""), "the bind address (including port) for git-sync's HTTP endpoint") @@ -485,6 +487,14 @@ func main() { } } + // This needs to be after all other git-related config flags. + if *flGitConfig != "" { + if err := setupExtraGitConfigs(ctx, *flGitConfig); err != nil { + log.Error(err, "ERROR: can't set additional git configs") + os.Exit(1) + } + } + // The scope of the initialization context ends here, so we call cancel to release resources associated with it. cancel() @@ -941,12 +951,15 @@ func cmdForLog(command string, args ...string) string { if strings.ContainsAny(command, " \t\n") { command = fmt.Sprintf("%q", command) } + // Don't modify the passed-in args. + argsCopy := make([]string, len(args)) + copy(argsCopy, args) for i := range args { if strings.ContainsAny(args[i], " \t\n") { - args[i] = fmt.Sprintf("%q", args[i]) + argsCopy[i] = fmt.Sprintf("%q", args[i]) } } - return command + " " + strings.Join(args, " ") + return command + " " + strings.Join(argsCopy, " ") } func runCommand(ctx context.Context, cwd, command string, args ...string) (string, error) { @@ -1039,8 +1052,7 @@ func (git *repoSync) SetupCookieFile(ctx context.Context) error { return fmt.Errorf("can't access git cookiefile: %w", err) } - if _, err = runCommand(ctx, "", - git.cmd, "config", "--global", "http.cookiefile", pathToCookieFile); err != nil { + if _, err = runCommand(ctx, "", git.cmd, "config", "--global", "http.cookiefile", pathToCookieFile); err != nil { return fmt.Errorf("can't configure git cookiefile: %w", err) } @@ -1102,6 +1114,167 @@ func (git *repoSync) CallAskPassURL(ctx context.Context) error { return nil } +func setupExtraGitConfigs(ctx context.Context, configsFlag string) error { + log.V(1).Info("setting additional git configs") + + configs, err := parseGitConfigs(configsFlag) + if err != nil { + return fmt.Errorf("can't parse --git-config flag: %v", err) + } + for _, kv := range configs { + if _, err := runCommand(ctx, "", *flGitCmd, "config", "--global", kv.key, kv.val); err != nil { + return fmt.Errorf("error configuring additional git configs %q %q: %v", kv.key, kv.val, err) + } + } + + return nil +} + +type keyVal struct { + key string + val string +} + +func parseGitConfigs(configsFlag string) ([]keyVal, error) { + ch := make(chan rune) + stop := make(chan bool) + go func() { + for _, r := range configsFlag { + select { + case <-stop: + break + default: + ch <- r + } + } + close(ch) + return + }() + + result := []keyVal{} + + // This assumes it is at the start of a key. + for { + cur := keyVal{} + var err error + + // Peek and see if we have a key. + if r, ok := <-ch; !ok { + break + } else { + cur.key, err = parseGitConfigKey(r, ch) + if err != nil { + return nil, err + } + } + + // Peek and see if we have a value. + if r, ok := <-ch; !ok { + return nil, fmt.Errorf("key %q: no value", cur.key) + } else { + if r == '"' { + cur.val, err = parseGitConfigQVal(ch) + if err != nil { + return nil, fmt.Errorf("key %q: %v", cur.key, err) + } + } else { + cur.val, err = parseGitConfigVal(r, ch) + if err != nil { + return nil, fmt.Errorf("key %q: %v", cur.key, err) + } + } + } + + result = append(result, cur) + } + + return result, nil +} + +func parseGitConfigKey(r rune, ch <-chan rune) (string, error) { + buf := make([]rune, 0, 64) + buf = append(buf, r) + + for r := range ch { + switch { + case r == ':': + return string(buf), nil + default: + // This can accumulate things that git doesn't allow, but we'll + // just let git handle it, rather than try to pre-validate to their + // spec. + buf = append(buf, r) + } + } + return "", fmt.Errorf("unexpected end of key: %q", string(buf)) +} + +func parseGitConfigQVal(ch <-chan rune) (string, error) { + buf := make([]rune, 0, 64) + + for r := range ch { + switch r { + case '\\': + if e, err := unescape(ch); err != nil { + return "", err + } else { + buf = append(buf, e) + } + case '"': + // Once we have a closing quote, the next must be either a comma or + // end-of-string. This helps reset the state for the next key, if + // there is one. + r, ok := <-ch + if ok && r != ',' { + return "", fmt.Errorf("unexpected trailing character '%c'", r) + } + return string(buf), nil + default: + buf = append(buf, r) + } + } + return "", fmt.Errorf("unexpected end of value: %q", string(buf)) +} + +func parseGitConfigVal(r rune, ch <-chan rune) (string, error) { + buf := make([]rune, 0, 64) + buf = append(buf, r) + + for r := range ch { + switch r { + case '\\': + if r, err := unescape(ch); err != nil { + return "", err + } else { + buf = append(buf, r) + } + case ',': + return string(buf), nil + default: + buf = append(buf, r) + } + } + // We ran out of characters, but that's OK. + return string(buf), nil +} + +// unescape processes most of the documented escapes that git config supports. +func unescape(ch <-chan rune) (rune, error) { + r, ok := <-ch + if !ok { + return 0, fmt.Errorf("unexpected end of escape sequence") + } + switch r { + case 'n': + return '\n', nil + case 't': + return '\t', nil + case '"', ',', '\\': + return r, nil + } + return 0, fmt.Errorf("unsupported escape character: '%c'", r) +} + // This string is formatted for 80 columns. Please keep it that way. // DO NOT USE TABS. var manual = ` @@ -1164,17 +1337,20 @@ OPTIONS Create a shallow clone with history truncated to the specified number of commits. - --link , $GIT_SYNC_LINK - The name of the final symlink (under --root) which will point to the - current git worktree. This must be a filename, not a path, and may - not start with a period. The destination of this link (i.e. - readlink()) is the currently checked out SHA. (default: the leaf - dir of --repo) - --git , $GIT_SYNC_GIT The git command to run (subject to PATH search, mostly for testing). (default: git) + --git-config , $GIT_SYNC_GIT_CONFIG + Additional git config options in 'key1:val1,key2:val2' format. The + key parts are passed to 'git config' and must be valid syntax for + that command. The val parts can be either quoted or unquoted + values. For all values the following escape sequences are + supported: '\n' => [newline], '\t' => [tab], '\"' => '"', '\,' => + ',', '\\' => '\'. Within unquoted values, commas MUST be escaped. + Within quoted values, commas MAY be escaped, but are not required + to be. Any other escape sequence is an error. (default: "") + -h, --help Print help text and exit. @@ -1190,6 +1366,13 @@ OPTIONS Enable the pprof debug endpoints on git-sync's HTTP endpoint (see --http-bind). (default: false) + --link , $GIT_SYNC_LINK + The name of the final symlink (under --root) which will point to the + current git worktree. This must be a filename, not a path, and may + not start with a period. The destination of this link (i.e. + readlink()) is the currently checked out SHA. (default: the leaf + dir of --repo) + --man Print this manual and exit. diff --git a/cmd/git-sync/main_test.go b/cmd/git-sync/main_test.go index 565da94c6..65df7f77a 100644 --- a/cmd/git-sync/main_test.go +++ b/cmd/git-sync/main_test.go @@ -18,6 +18,7 @@ package main import ( "os" + "reflect" "strings" "testing" "time" @@ -150,3 +151,91 @@ func TestManualHasNoTabs(t *testing.T) { t.Fatal("the manual text contains a tab") } } + +func TestParseGitConfigs(t *testing.T) { + cases := []struct { + name string + input string + expect []keyVal + fail bool + }{{ + name: "empty", + input: ``, + expect: []keyVal{}, + }, { + name: "one-pair", + input: `k:v`, + expect: []keyVal{keyVal{"k", "v"}}, + }, { + name: "one-pair-qval", + input: `k:"v"`, + expect: []keyVal{keyVal{"k", "v"}}, + }, { + name: "garbage", + input: `abc123`, + fail: true, + }, { + name: "invalid-val", + input: `k:v\xv`, + fail: true, + }, { + name: "invalid-qval", + input: `k:"v\xv"`, + fail: true, + }, { + name: "two-pair", + input: `k1:v1,k2:v2`, + expect: []keyVal{{"k1", "v1"}, {"k2", "v2"}}, + }, { + name: "val-spaces", + input: `k1:v 1,k2:v 2`, + expect: []keyVal{{"k1", "v 1"}, {"k2", "v 2"}}, + }, { + name: "qval-spaces", + input: `k1:" v 1 ",k2:" v 2 "`, + expect: []keyVal{{"k1", " v 1 "}, {"k2", " v 2 "}}, + }, { + name: "mix-val-qval", + input: `k1:v 1,k2:" v 2 "`, + expect: []keyVal{{"k1", "v 1"}, {"k2", " v 2 "}}, + }, { + name: "garbage-after-qval", + input: `k1:"v1"x,k2:"v2"`, + fail: true, + }, { + name: "dangling-comma", + input: `k1:"v1",k2:"v2",`, + expect: []keyVal{{"k1", "v1"}, {"k2", "v2"}}, + }, { + name: "val-escapes", + input: `k1:v\n\t\\\"\,1`, + expect: []keyVal{{"k1", "v\n\t\\\",1"}}, + }, { + name: "qval-escapes", + input: `k1:"v\n\t\\\"\,1"`, + expect: []keyVal{{"k1", "v\n\t\\\",1"}}, + }, { + name: "qval-comma", + input: `k1:"v,1"`, + expect: []keyVal{{"k1", "v,1"}}, + }, { + name: "qval-missing-close", + input: `k1:"v1`, + fail: true, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + kvs, err := parseGitConfigs(tc.input) + if err != nil && !tc.fail { + t.Errorf("unexpected error: %v", err) + } + if err == nil && tc.fail { + t.Errorf("unexpected success") + } + if !reflect.DeepEqual(kvs, tc.expect) { + t.Errorf("bad result: expected %v, got %v", tc.expect, kvs) + } + }) + } +} diff --git a/test_e2e.sh b/test_e2e.sh index 4ce211cf0..878fb59b1 100755 --- a/test_e2e.sh +++ b/test_e2e.sh @@ -1233,6 +1233,27 @@ assert_file_eq "$ROOT"/link/file "$TESTCASE" # Wrap up pass +############################################## +# Test additional git configs +############################################## +testcase "additional-git-configs" +echo "$TESTCASE" > "$REPO"/file +git -C "$REPO" commit -qam "$TESTCASE" +GIT_SYNC \ + --one-time \ + --repo="file://$REPO" \ + --branch=e2e-branch \ + --rev=HEAD \ + --root="$ROOT" \ + --link="link" \ + --git-config='http.postBuffer:10485760,sect.k1:"a val",sect.k2:another val' \ + > "$DIR"/log."$TESTCASE" 2>&1 +assert_link_exists "$ROOT"/link +assert_file_exists "$ROOT"/link/file +assert_file_eq "$ROOT"/link/file "$TESTCASE" +# Wrap up +pass + echo echo "all tests passed: cleaning up $DIR" rm -rf "$DIR"