402 lines
14 KiB
Go
402 lines
14 KiB
Go
package subproc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGitFinalEnvironment(t *testing.T) {
|
|
fakeGitDir := t.TempDir()
|
|
fakeGit := filepath.Join(fakeGitDir, "git")
|
|
if err := os.WriteFile(fakeGit, []byte(`#!/bin/sh
|
|
printf 'GIT_TERMINAL_PROMPT=%s\n' "${GIT_TERMINAL_PROMPT-}"
|
|
printf 'GCM_INTERACTIVE=%s\n' "${GCM_INTERACTIVE-}"
|
|
printf 'GCM_GUI_PROMPT=%s\n' "${GCM_GUI_PROMPT-}"
|
|
printf 'GIT_ASKPASS=%s\n' "${GIT_ASKPASS-}"
|
|
printf 'SSH_ASKPASS=%s\n' "${SSH_ASKPASS-}"
|
|
printf 'SSH_ASKPASS_REQUIRE=%s\n' "${SSH_ASKPASS_REQUIRE-}"
|
|
printf 'GIT_SSH_COMMAND=%s\n' "${GIT_SSH_COMMAND-}"
|
|
printf 'GIT_CONFIG_COUNT=%s\n' "${GIT_CONFIG_COUNT-}"
|
|
for i in 0 1 2 3 4 5; do
|
|
eval "key=\${GIT_CONFIG_KEY_$i-}"
|
|
eval "value=\${GIT_CONFIG_VALUE_$i-}"
|
|
if [ -n "$key" ]; then
|
|
printf 'GIT_CONFIG_%s=%s\n' "$i" "$key=$value"
|
|
fi
|
|
done
|
|
printf 'SELECTED_CREDENTIAL_DIR=%s\n' "${SELECTED_CREDENTIAL_DIR-}"
|
|
printf 'AMBIENT_ONLY=%s\n' "${AMBIENT_ONLY-}"
|
|
`), 0o700); err != nil {
|
|
t.Fatalf("write fake git: %v", err)
|
|
}
|
|
t.Setenv("PATH", fakeGitDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("AMBIENT_ONLY", "ambient-value")
|
|
|
|
cmd := NewGitCommand(context.Background(), "credential", "fill")
|
|
cmd.Env = []string{
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"SELECTED_CREDENTIAL_DIR=/selected/credentials",
|
|
"GIT_TERMINAL_PROMPT=1",
|
|
"GCM_INTERACTIVE=Always",
|
|
"GCM_GUI_PROMPT=1",
|
|
"GIT_ASKPASS=ambient-askpass",
|
|
"SSH_ASKPASS=ambient-ssh-askpass",
|
|
"SSH_ASKPASS_REQUIRE=force",
|
|
"GIT_SSH_COMMAND=ssh -i /selected/key -oBatchMode=no",
|
|
"GIT_CONFIG_COUNT=6",
|
|
"GIT_CONFIG_KEY_0=credential.helper",
|
|
"GIT_CONFIG_VALUE_0=",
|
|
"GIT_CONFIG_KEY_1=credential.helper",
|
|
"GIT_CONFIG_VALUE_1=!user-helper",
|
|
"GIT_CONFIG_KEY_2=credential.helper",
|
|
"GIT_CONFIG_VALUE_2=cache",
|
|
"GIT_CONFIG_KEY_3=core.hooksPath",
|
|
"GIT_CONFIG_VALUE_3=/selected/hooks",
|
|
"GIT_CONFIG_KEY_4=notes.augment.mergeStrategy",
|
|
"GIT_CONFIG_VALUE_4=union",
|
|
"GIT_CONFIG_KEY_5=credential.https://github.com.helper",
|
|
"GIT_CONFIG_VALUE_5=!kandev-host-gh-bridge",
|
|
}
|
|
|
|
out, err := RunGitCombinedOutputClass(context.Background(), GitInteractive, cmd)
|
|
if err != nil {
|
|
t.Fatalf("run fake git: %v\n%s", err, out)
|
|
}
|
|
got := gitEnvironmentLines(string(out))
|
|
want := map[string]string{
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"GCM_INTERACTIVE": "Never",
|
|
"GCM_GUI_PROMPT": "0",
|
|
"GIT_ASKPASS": "exit 1",
|
|
"SSH_ASKPASS": "exit 1",
|
|
"SSH_ASKPASS_REQUIRE": "never",
|
|
"GIT_SSH_COMMAND": "ssh -oBatchMode=yes -i /selected/key -oBatchMode=no",
|
|
"GIT_CONFIG_COUNT": "6",
|
|
"GIT_CONFIG_0": "credential.helper=",
|
|
"GIT_CONFIG_1": "credential.helper=!user-helper",
|
|
"GIT_CONFIG_2": "credential.helper=cache",
|
|
"GIT_CONFIG_3": "core.hooksPath=/selected/hooks",
|
|
"GIT_CONFIG_4": "notes.augment.mergeStrategy=union",
|
|
"GIT_CONFIG_5": "credential.https://github.com.helper=!kandev-host-gh-bridge",
|
|
"SELECTED_CREDENTIAL_DIR": "/selected/credentials",
|
|
"AMBIENT_ONLY": "",
|
|
}
|
|
for key, wantValue := range want {
|
|
if gotValue := got[key]; gotValue != wantValue {
|
|
t.Errorf("%s = %q, want %q", key, gotValue, wantValue)
|
|
}
|
|
}
|
|
if strings.Contains(string(out), "ambient-askpass") || strings.Contains(string(out), "ambient-ssh-askpass") {
|
|
t.Fatalf("inherited interactive askpass reached Git: %s", out)
|
|
}
|
|
}
|
|
|
|
func gitEnvironmentLines(output string) map[string]string {
|
|
result := make(map[string]string)
|
|
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if ok {
|
|
result[key] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func TestPrepareGitCommandPreservesNilAndDuplicateEnvironment(t *testing.T) {
|
|
t.Setenv("AMBIENT_ONLY", "ambient-value")
|
|
nilEnvCommand := exec.Command("git", "version")
|
|
PrepareGitCommand(nilEnvCommand)
|
|
if got, ok := environmentValue(nilEnvCommand.Env, "AMBIENT_ONLY"); !ok || got != "ambient-value" {
|
|
t.Fatalf("nil command environment lost inherited value: %q, present=%t", got, ok)
|
|
}
|
|
|
|
env := []string{
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"SELECTED=first",
|
|
"SELECTED=last",
|
|
"GIT_CONFIG_COUNT=2",
|
|
"GIT_CONFIG_KEY_0=credential.helper",
|
|
"GIT_CONFIG_VALUE_0=",
|
|
"GIT_CONFIG_KEY_1=notes.augment.mergeStrategy",
|
|
"GIT_CONFIG_VALUE_1=union",
|
|
}
|
|
prepared := PrepareGitEnvironment(env)
|
|
selectedCount := 0
|
|
for _, entry := range prepared {
|
|
if strings.HasPrefix(entry, "SELECTED=") {
|
|
selectedCount++
|
|
}
|
|
}
|
|
if selectedCount != 1 {
|
|
t.Fatalf("SELECTED appears %d times, want one: %v", selectedCount, prepared)
|
|
}
|
|
if got, ok := environmentValue(prepared, "SELECTED"); !ok || got != "last" {
|
|
t.Fatalf("SELECTED = %q, present=%t, want last duplicate", got, ok)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
prepared = append(prepared, "selected=case-insensitive-last")
|
|
prepared = collapseEnvironmentAssignments(prepared)
|
|
if got, ok := environmentValue(prepared, "SELECTED"); !ok || got != "case-insensitive-last" {
|
|
t.Fatalf("case-insensitive duplicate = %q, present=%t", got, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGitSSHCommandPreservation(t *testing.T) {
|
|
tests := map[string]string{
|
|
"ssh -i /selected/key -oBatchMode=no": "ssh -oBatchMode=yes -i /selected/key -oBatchMode=no",
|
|
`'path with spaces/ssh' -i key`: `'path with spaces/ssh' -oBatchMode=yes -i key`,
|
|
"ssh -oBatchMode=yes -i key": "ssh -oBatchMode=yes -i key",
|
|
"ssh -o BatchMode=yes -i key": "ssh -oBatchMode=yes -i key",
|
|
"ssh '-oBatchMode=yes' -i key": "ssh -oBatchMode=yes -i key",
|
|
"ssh -o ProxyCommand='ssh -oBatchMode=yes bastion -W %h:%p' -i key": "ssh -oBatchMode=yes -o ProxyCommand='ssh -oBatchMode=yes bastion -W %h:%p' -i key",
|
|
"env FOO=bar ssh -i key": "ssh -oBatchMode=yes",
|
|
"FOO=bar ssh -i key": "ssh -oBatchMode=yes",
|
|
"exec ssh -i key": "ssh -oBatchMode=yes",
|
|
"plink -i key": "ssh -oBatchMode=yes",
|
|
}
|
|
for command, want := range tests {
|
|
if got := ForceGitSSHBatchMode(command); got != want {
|
|
t.Errorf("ForceGitSSHBatchMode(%q) = %q, want %q", command, got, want)
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("git", "version")
|
|
cmd.Env = []string{"GIT_SSH=/selected/path/ssh"}
|
|
PrepareGitCommand(cmd)
|
|
got, ok := environmentValue(cmd.Env, "GIT_SSH_COMMAND")
|
|
if !ok || got != "/selected/path/ssh -oBatchMode=yes" {
|
|
t.Fatalf("GIT_SSH_COMMAND = %q, present=%t, want direct GIT_SSH path with BatchMode", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestGitRunnersApplyFinalEnvironment(t *testing.T) {
|
|
fakeGitDir := t.TempDir()
|
|
fakeGit := filepath.Join(fakeGitDir, "git")
|
|
if err := os.WriteFile(fakeGit, []byte(`#!/bin/sh
|
|
printf 'GIT_TERMINAL_PROMPT=%s\n' "${GIT_TERMINAL_PROMPT-}"
|
|
printf 'GCM_INTERACTIVE=%s\n' "${GCM_INTERACTIVE-}"
|
|
printf 'GCM_GUI_PROMPT=%s\n' "${GCM_GUI_PROMPT-}"
|
|
printf 'GIT_ASKPASS=%s\n' "${GIT_ASKPASS-}"
|
|
printf 'SSH_ASKPASS=%s\n' "${SSH_ASKPASS-}"
|
|
printf 'SSH_ASKPASS_REQUIRE=%s\n' "${SSH_ASKPASS_REQUIRE-}"
|
|
`), 0o700); err != nil {
|
|
t.Fatalf("write fake git: %v", err)
|
|
}
|
|
t.Setenv("PATH", fakeGitDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
env := []string{
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"GIT_TERMINAL_PROMPT=1",
|
|
"GCM_INTERACTIVE=Always",
|
|
"GCM_GUI_PROMPT=1",
|
|
"GIT_ASKPASS=ambient-askpass",
|
|
"SSH_ASKPASS=ambient-ssh-askpass",
|
|
"SSH_ASKPASS_REQUIRE=force",
|
|
"GIT_SSH_COMMAND=ssh -oBatchMode=no",
|
|
}
|
|
assertPolicy := func(t *testing.T, output []byte, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("fake git failed: %v\n%s", err, output)
|
|
}
|
|
got := gitEnvironmentLines(string(output))
|
|
for key, want := range map[string]string{
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"GCM_INTERACTIVE": "Never",
|
|
"GCM_GUI_PROMPT": "0",
|
|
"GIT_ASKPASS": "exit 1",
|
|
"SSH_ASKPASS": "exit 1",
|
|
"SSH_ASKPASS_REQUIRE": "never",
|
|
} {
|
|
if got[key] != want {
|
|
t.Errorf("%s = %q, want %q", key, got[key], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("run", func(t *testing.T) {
|
|
cmd := NewGitCommand(context.Background(), "version")
|
|
cmd.Env = append([]string(nil), env...)
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
err := RunGitClass(context.Background(), GitLifecycle, cmd)
|
|
assertPolicy(t, stdout.Bytes(), err)
|
|
})
|
|
|
|
t.Run("combined-output", func(t *testing.T) {
|
|
cmd := NewGitCommand(context.Background(), "version")
|
|
cmd.Env = append([]string(nil), env...)
|
|
output, err := RunGitCombinedOutputClass(context.Background(), GitLifecycle, cmd)
|
|
assertPolicy(t, output, err)
|
|
})
|
|
|
|
t.Run("output", func(t *testing.T) {
|
|
cmd := NewGitCommand(context.Background(), "version")
|
|
cmd.Env = append([]string(nil), env...)
|
|
output, err := RunGitOutputClass(context.Background(), GitLifecycle, cmd)
|
|
assertPolicy(t, output, err)
|
|
})
|
|
|
|
t.Run("combined-after-acquire", func(t *testing.T) {
|
|
output, runErr, execErr := RunGitCombinedAfterAcquire(
|
|
context.Background(), GitLifecycle, time.Second,
|
|
func(execCtx context.Context) *exec.Cmd {
|
|
cmd := NewGitCommand(execCtx, "version")
|
|
cmd.Env = append([]string(nil), env...)
|
|
return cmd
|
|
},
|
|
)
|
|
if execErr != nil {
|
|
t.Fatalf("execution context: %v", execErr)
|
|
}
|
|
assertPolicy(t, output, runErr)
|
|
})
|
|
|
|
t.Run("output-after-acquire", func(t *testing.T) {
|
|
output, runErr, execErr := RunGitOutputAfterAcquire(
|
|
context.Background(), GitLifecycle, time.Second,
|
|
func(execCtx context.Context) *exec.Cmd {
|
|
cmd := NewGitCommand(execCtx, "version")
|
|
cmd.Env = append([]string(nil), env...)
|
|
return cmd
|
|
},
|
|
)
|
|
if execErr != nil {
|
|
t.Fatalf("execution context: %v", execErr)
|
|
}
|
|
assertPolicy(t, output, runErr)
|
|
})
|
|
|
|
t.Run("after-acquire", func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
runErr, execErr := RunGitAfterAcquire(
|
|
context.Background(), GitLifecycle, time.Second,
|
|
func(execCtx context.Context) *exec.Cmd {
|
|
cmd := NewGitCommand(execCtx, "version")
|
|
cmd.Env = append([]string(nil), env...)
|
|
cmd.Stdout = &stdout
|
|
return cmd
|
|
},
|
|
)
|
|
if execErr != nil {
|
|
t.Fatalf("execution context: %v", execErr)
|
|
}
|
|
assertPolicy(t, stdout.Bytes(), runErr)
|
|
})
|
|
}
|
|
|
|
func TestGitCredentialHTTPSelectedScope(t *testing.T) {
|
|
root := t.TempDir()
|
|
repo := filepath.Join(root, "repo.git")
|
|
runTestGit(t, root, "init", "--bare", repo)
|
|
|
|
var authenticated int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
username, password, ok := request.BasicAuth()
|
|
if !ok || username != "selected-user" || password != "selected-token" {
|
|
response.Header().Set("WWW-Authenticate", `Basic realm="git-test"`)
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
atomic.AddInt32(&authenticated, 1)
|
|
serveGitHTTPBackend(response, request, root)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
host := strings.TrimPrefix(server.URL, "http://")
|
|
environment := []string{
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"GIT_CONFIG_NOSYSTEM=1",
|
|
"GIT_TERMINAL_PROMPT=1",
|
|
"GIT_CONFIG_COUNT=2",
|
|
"GIT_CONFIG_KEY_0=credential.helper",
|
|
"GIT_CONFIG_VALUE_0=!false",
|
|
"GIT_CONFIG_KEY_1=credential.http://" + host + ".helper",
|
|
"GIT_CONFIG_VALUE_1=!f() { printf 'username=selected-user\\npassword=selected-token\\n'; }; f",
|
|
}
|
|
|
|
out, runErr, execErr := RunGitOutputAfterAcquire(
|
|
context.Background(), GitInteractive, 5*time.Second, func(execCtx context.Context) *exec.Cmd {
|
|
cmd := NewGitCommand(execCtx, "ls-remote", server.URL+"/repo.git")
|
|
cmd.Env = append([]string(nil), environment...)
|
|
return cmd
|
|
},
|
|
)
|
|
if runErr != nil || execErr != nil {
|
|
t.Fatalf("authenticated HTTP ls-remote failed: run=%v exec=%v\n%s", runErr, execErr, out)
|
|
}
|
|
if atomic.LoadInt32(&authenticated) == 0 {
|
|
t.Fatal("HTTP Git fixture did not observe a request with selected credentials")
|
|
}
|
|
if strings.Contains(string(out), "selected-token") {
|
|
t.Fatalf("Git output leaked selected credential: %q", out)
|
|
}
|
|
}
|
|
func runTestGit(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v failed: %v\n%s", args, err, output)
|
|
}
|
|
}
|
|
|
|
func serveGitHTTPBackend(response http.ResponseWriter, request *http.Request, root string) {
|
|
cmd := exec.Command("git", "http-backend")
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_PROJECT_ROOT="+root,
|
|
"GIT_HTTP_EXPORT_ALL=1",
|
|
"PATH_INFO="+request.URL.Path,
|
|
"QUERY_STRING="+request.URL.RawQuery,
|
|
"REQUEST_METHOD="+request.Method,
|
|
"REMOTE_ADDR=127.0.0.1",
|
|
"CONTENT_TYPE="+request.Header.Get("Content-Type"),
|
|
"CONTENT_LENGTH="+request.Header.Get("Content-Length"),
|
|
)
|
|
cmd.Stdin = request.Body
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusBadGateway)
|
|
_, _ = fmt.Fprintf(response, "git http-backend failed: %v", err)
|
|
return
|
|
}
|
|
headerEnd := strings.Index(string(output), "\r\n\r\n")
|
|
delimiterLength := 4
|
|
if headerEnd < 0 {
|
|
headerEnd = strings.Index(string(output), "\n\n")
|
|
delimiterLength = 2
|
|
}
|
|
if headerEnd < 0 {
|
|
response.WriteHeader(http.StatusBadGateway)
|
|
return
|
|
}
|
|
for _, line := range strings.Split(string(output[:headerEnd]), "\n") {
|
|
line = strings.TrimSuffix(line, "\r")
|
|
key, value, ok := strings.Cut(line, ": ")
|
|
if !ok {
|
|
continue
|
|
}
|
|
if key == "Status" {
|
|
var status int
|
|
_, _ = fmt.Sscanf(value, "%d", &status)
|
|
if status != 0 {
|
|
response.WriteHeader(status)
|
|
}
|
|
continue
|
|
}
|
|
response.Header().Set(key, value)
|
|
}
|
|
_, _ = response.Write(output[headerEnd+delimiterLength:])
|
|
}
|