Files
kandev/apps/backend/internal/common/subproc/git_noninteractive_test.go
T
2026-09-13 21:57:54 +01:00

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:])
}