diff --git a/main.go b/main.go index 846ad24e8..a201bd038 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ package main // import "k8s.io/git-sync/cmd/git-sync" import ( "context" "crypto/md5" + "encoding/json" "errors" "fmt" "io" @@ -105,6 +106,13 @@ const ( const defaultDirMode = os.FileMode(0775) // subject to umask +type credential struct { + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + PasswordFile string `json:"password-file"` +} + func envString(def string, key string, alts ...string) string { if val := os.Getenv(key); val != "" { return val @@ -135,6 +143,54 @@ func envStringArray(def string, key string, alts ...string) []string { return parse(def) } +func envStringArrayJSONOrError(def string, key string, alts ...string) ([]string, error) { + parse := func(key, val string) ([]string, error) { + s := strings.TrimSpace(val) + if s == "" { + return nil, nil + } + // If it tastes like an object... + if s[0] == '{' { + return []string{s}, nil + } + // If it tastes like an array... + if s[0] == '[' { + // Parse into an array of "stuff to decode later". + var arr []json.RawMessage + if err := json.Unmarshal([]byte(s), &arr); err != nil { + return nil, fmt.Errorf("ERROR: invalid JSON list env %s=%q: %w", key, val, err) + } + // Re-encode as []string + ret := []string{} + for _, rm := range arr { + ret = append(ret, string(rm)) + } + return ret, nil + } + return nil, fmt.Errorf("ERROR: invalid JSON env %s=%q: not a list or object", key, val) + } + + if val := os.Getenv(key); val != "" { + return parse(key, val) + } + for _, alt := range alts { + if val := os.Getenv(alt); val != "" { + fmt.Fprintf(os.Stderr, "env %s has been deprecated, use %s instead\n", alt, key) + return parse(alt, val) + } + } + return parse("", def) +} +func envStringArrayJSON(def string, key string, alts ...string) []string { + val, err := envStringArrayJSONOrError(def, key, alts...) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + return nil + } + return val +} + func envBoolOrError(def bool, key string, alts ...string) (bool, error) { parse := func(key, val string) (bool, error) { parsed, err := strconv.ParseBool(val) @@ -450,6 +506,9 @@ func main() { flPasswordFile := pflag.String("password-file", envString("", "GITSYNC_PASSWORD_FILE", "GIT_SYNC_PASSWORD_FILE"), "the file from which the password or personal access token for git auth will be sourced") + flCredentials := pflag.StringArray("credential", + envStringArrayJSON("", "GITSYNC_CREDENTIAL"), + "one or more credentials (see --man for details) available for authentication") flSSH := pflag.Bool("ssh", envBool(false, "GITSYNC_SSH", "GIT_SYNC_SSH"), @@ -697,6 +756,32 @@ func main() { handleConfigError(log, true, "ERROR: --password or --password-file must be set when --username is specified") } } + //FIXME: mutex wih flCredentials? + + credentials := []credential{} + if len(*flCredentials) > 0 { + for _, s := range *flCredentials { + cred := credential{} + if err := json.Unmarshal([]byte(s), &cred); err != nil { + handleConfigError(log, true, "ERROR: can't parse --credential payload: %v", err) + } + if cred.URL == "" { + //FIXME: can it default to --repo? + handleConfigError(log, true, "ERROR: --credential URL must be specified") + } + if cred.Username == "" { + handleConfigError(log, true, "ERROR: --credential username must be specified") + } + if cred.Password == "" && cred.PasswordFile == "" { + handleConfigError(log, true, "ERROR: --credential password or password-file must be set") + } + if cred.Password != "" && cred.PasswordFile != "" { + handleConfigError(log, true, "ERROR: only one of --credential password and password-file may be specified") + } + //FIXME: askpass for this purpose, too? + credentials = append(credentials, cred) + } + } if *flSSH { if *flUsername != "" { @@ -708,6 +793,7 @@ func main() { if *flPasswordFile != "" { handleConfigError(log, true, "ERROR: only one of --ssh and --password-file may be specified") } + //FIXME: mutex wih flCredentials? if *flAskPassURL != "" { handleConfigError(log, true, "ERROR: only one of --ssh and --askpass-url may be specified") } @@ -826,6 +912,7 @@ func main() { os.Exit(1) } + // FIXME: merge into flCredentials if *flUsername != "" { if *flPasswordFile != "" { passwordFileBytes, err := os.ReadFile(*flPasswordFile) @@ -836,6 +923,17 @@ func main() { *flPassword = string(passwordFileBytes) } } + //FIXME: merge + for _, cred := range credentials { + if cred.PasswordFile != "" { + passwordFileBytes, err := os.ReadFile(cred.PasswordFile) + if err != nil { + log.Error(err, "can't read password file", "file", cred.PasswordFile) + os.Exit(1) + } + cred.Password = string(passwordFileBytes) + } + } if *flSSH { if err := git.SetupGitSSH(*flSSHKnownHosts, *flSSHKeyFiles, *flSSHKnownHostsFile); err != nil { @@ -957,9 +1055,15 @@ func main() { // Craft a function that can be called to refresh credentials when needed. refreshCreds := func(ctx context.Context) error { + //FIXME: still mutually exclusive? // These should all be mutually-exclusive configs. if *flUsername != "" { - if err := git.StoreCredentials(ctx, *flUsername, *flPassword); err != nil { + if err := git.StoreCredentials(ctx, git.repo, *flUsername, *flPassword); err != nil { + return err + } + } + for _, cred := range credentials { + if err := git.StoreCredentials(ctx, cred.URL, cred.Username, cred.Password); err != nil { return err } } @@ -1144,6 +1248,10 @@ func logSafeFlags() []string { if arg == "repo" { val = redactURL(val) } + // Handle --credential + if arg == "credential" { + //FIXME: convert the flag to a []credential and blank fields here + } // Don't log empty values if val == "" { return @@ -1931,12 +2039,12 @@ func md5sum(s string) string { return fmt.Sprintf("%x", h.Sum(nil)) } -// StoreCredentials stores the username and password for later use. -func (git *repoSync) StoreCredentials(ctx context.Context, username, password string) error { +// StoreCredentials stores a username and password for later use. +func (git *repoSync) StoreCredentials(ctx context.Context, url, username, password string) error { git.log.V(1).Info("storing git credentials") - git.log.V(9).Info("md5 of credentials", "username", md5sum(username), "password", md5sum(password)) + git.log.V(9).Info("md5 of credentials", "url", url, "username", md5sum(username), "password", md5sum(password)) - creds := fmt.Sprintf("url=%v\nusername=%v\npassword=%v\n", git.repo, username, password) + creds := fmt.Sprintf("url=%v\nusername=%v\npassword=%v\n", url, username, password) _, _, err := git.RunWithStdin(ctx, "", creds, "credential", "approve") if err != nil { return fmt.Errorf("can't configure git credentials: %w", err) @@ -2050,7 +2158,8 @@ func (git *repoSync) CallAskPassURL(ctx context.Context) error { } } - if err := git.StoreCredentials(ctx, username, password); err != nil { + //FIXME: support multiple? + if err := git.StoreCredentials(ctx, git.repo, username, password); err != nil { return err } @@ -2330,6 +2439,9 @@ OPTIONS Use a git cookiefile (/etc/git-secret/cookie_file) for authentication. + --credential , $GITSYNC_CREDENTIAL + FIXME + --depth , $GITSYNC_DEPTH Create a shallow clone with history truncated to the specified number of commits. If not specified, this defaults to syncing a diff --git a/main_test.go b/main_test.go index 3f85bdb5c..487c63ab0 100644 --- a/main_test.go +++ b/main_test.go @@ -175,6 +175,45 @@ func TestEnvDuration(t *testing.T) { } } +func TestEnvStringArrayJSON(t *testing.T) { + mkslice := func(args ...string) []string { + return args + } + + cases := []struct { + value string + def string + exp []string + err bool + }{ + {"", "", nil, false}, + {" ", "", nil, false}, + {"", `{"a string"}`, mkslice(`{"a string"}`), false}, + {"{}", "", mkslice("{}"), false}, + {" {} ", "", mkslice("{}"), false}, + {`{"a string"}`, "", mkslice(`{"a string"}`), false}, + {"[]", "", []string{}, false}, + {" [] ", "", []string{}, false}, + {`["a string"]`, "", mkslice(`"a string"`), false}, + {`[{"a": "string"}]`, "", mkslice(`{"a": "string"}`), false}, + {`[{"a": "string"}, {"b": "smart"}]`, "", mkslice(`{"a": "string"}`, `{"b": "smart"}`), false}, + {"a string", "", nil, true}, + } + + for _, testCase := range cases { + os.Setenv(testKey, testCase.value) + val, err := envStringArrayJSONOrError(testCase.def, testKey) + if err != nil && !testCase.err { + t.Fatalf("%q: unexpected error: %v", testCase.value, err) + } + if err == nil && testCase.err { + t.Fatalf("%q: unexpected success", testCase.value) + } + if !reflect.DeepEqual(val, testCase.exp) { + t.Fatalf("%q: expected %+#v but %+#v returned", testCase.value, testCase.exp, val) + } + } +} func TestMakeAbsPath(t *testing.T) { cases := []struct { path string diff --git a/test_e2e.sh b/test_e2e.sh index 0e399bae5..0c1fe602e 100755 --- a/test_e2e.sh +++ b/test_e2e.sh @@ -2848,7 +2848,6 @@ function e2e::submodule_sync_over_http_different_passwords() { git -C "$NESTED_SUBMODULE" add nested-submodule.file git -C "$NESTED_SUBMODULE" commit -aqm "init nested-submodule.file" - set -x # Run a git-over-SSH server. Use password "test1". echo 'test:$apr1$cXiFWR90$Pmoz7T8kEmlpC9Bpj4MX3.' > "$WORK/htpasswd.1" CTR_SUBSUB=$(docker_run \ @@ -2900,8 +2899,9 @@ function e2e::submodule_sync_over_http_different_passwords() { --repo="http://$IP/repo" \ --root="$ROOT" \ --link="link" \ - --username="test" \ - --password="test3" \ + --credential="{ \"url\": \"http://$IP_SUBSUB/repo\", \"username\": \"test\", \"password\": \"test1\" }" \ + --credential="{ \"url\": \"http://$IP_SUB/repo\", \"username\": \"test\", \"password\": \"test2\" }" \ + --credential="{ \"url\": \"http://$IP/repo\", \"username\": \"test\", \"password\": \"test3\" }" \ & wait_for_sync "${MAXWAIT}" assert_link_exists "$ROOT/link"