mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
# Conflicts: # internal/pipeline/think_stage.go # internal/pipeline/tool_stage.go # tests/integration/git_adapter_ssh_test.go
190 lines
5.6 KiB
Go
190 lines
5.6 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
// Phase 4 (issue #82) — SSH path end-to-end lifecycle test.
|
|
//
|
|
// Why no real sshd: spawning an OpenSSH daemon in CI is heavy (image must
|
|
// carry openssh-server) and the lifecycle properties we care about
|
|
// (tmpfile 0600, cleanup-on-success, cleanup-on-exec-failure, env shape
|
|
// reaching the child) are observable with a fake exec that just reads
|
|
// GIT_SSH_COMMAND and reports back. Real sshd is reserved for manual
|
|
// security review per Phase 6.
|
|
//
|
|
// This file proves:
|
|
// - The keypath surfaced via Injection.ScrubValues[0] is a real 0600
|
|
// tmpfile on disk while the exec runs.
|
|
// - cleanup() removes it. The file is gone after cleanup, regardless of
|
|
// exec success/failure.
|
|
// - GIT_SSH_COMMAND propagates through the env passed to the child.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
func makeTestKeyJSON(t *testing.T) []byte {
|
|
t.Helper()
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("gen key: %v", err)
|
|
}
|
|
block, err := ssh.MarshalPrivateKey(priv, "")
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(block.Bytes)
|
|
pem := "-----BEGIN OPENSSH PRIVATE KEY-----\n" + encoded + "\n-----END OPENSSH PRIVATE KEY-----\n"
|
|
blob, _ := json.Marshal(map[string]string{"key": pem})
|
|
return blob
|
|
}
|
|
|
|
func sshCredFor(t *testing.T, host string) *store.SecureCLIUserCredential {
|
|
t.Helper()
|
|
ct, hs := "ssh_key", host
|
|
return &store.SecureCLIUserCredential{
|
|
CredentialType: &ct,
|
|
HostScope: &hs,
|
|
EncryptedEnv: makeTestKeyJSON(t),
|
|
}
|
|
}
|
|
|
|
// TestGitAdapter_SSH_TmpfileLifecycle (AC2): keypath exists during exec, is
|
|
// 0600, and is removed after cleanup runs.
|
|
func TestGitAdapter_SSH_TmpfileLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cred := sshCredFor(t, "github.com")
|
|
adapter := tools.AdapterFor("git")
|
|
|
|
inj, err := adapter.Prepare(context.Background(), nil, cred,
|
|
[]string{"clone", "git@github.com:o/r.git", "/tmp/dst"})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
defer func() {
|
|
if inj.Cleanup != nil {
|
|
_ = inj.Cleanup()
|
|
}
|
|
}()
|
|
|
|
if len(inj.ScrubValues) != 1 {
|
|
t.Fatalf("want 1 scrub value (keypath), got %v", inj.ScrubValues)
|
|
}
|
|
keyPath := inj.ScrubValues[0]
|
|
|
|
// Exists + 0600 (POSIX only) during the exec window.
|
|
st, err := os.Stat(keyPath)
|
|
if err != nil {
|
|
t.Fatalf("keypath stat: %v", err)
|
|
}
|
|
if runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("keypath perms = %o, want 0600", st.Mode().Perm())
|
|
}
|
|
|
|
// Simulate the exec: spawn `env` (or `cmd /c set` on Windows) to
|
|
// observe GIT_SSH_COMMAND propagation. We don't run real git — the
|
|
// adapter's contract is "env reaches child", which is provable without
|
|
// involving git's transport layer.
|
|
env := append(os.Environ())
|
|
for k, v := range inj.Env {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
var cmd *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
cmd = exec.Command("cmd", "/c", "set")
|
|
} else {
|
|
cmd = exec.Command("env")
|
|
}
|
|
cmd.Env = env
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("env exec: %v", err)
|
|
}
|
|
if !strings.Contains(string(out), "GIT_SSH_COMMAND=ssh -i "+keyPath) {
|
|
t.Fatalf("GIT_SSH_COMMAND not propagated to child env:\n%s", out)
|
|
}
|
|
|
|
// Cleanup → file gone.
|
|
if err := inj.Cleanup(); err != nil {
|
|
t.Fatalf("cleanup: %v", err)
|
|
}
|
|
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
|
|
t.Fatalf("post-cleanup stat want ErrNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGitAdapter_SSH_CleanupOnExecFailure: cleanup runs even when the
|
|
// caller's exec exits non-zero. Mirrors what credentialed_exec.go does via
|
|
// `defer inj.Cleanup()`.
|
|
func TestGitAdapter_SSH_CleanupOnExecFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cred := sshCredFor(t, "github.com")
|
|
adapter := tools.AdapterFor("git")
|
|
|
|
inj, err := adapter.Prepare(context.Background(), nil, cred,
|
|
[]string{"clone", "git@github.com:o/r.git", "/tmp/dst"})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
keyPath := inj.ScrubValues[0]
|
|
|
|
// Mimic credentialed_exec.go: defer cleanup, run command, observe exit.
|
|
func() {
|
|
defer inj.Cleanup()
|
|
// Force a non-zero exit.
|
|
cmd := exec.Command(falseBin(), "anyarg")
|
|
_ = cmd.Run() // err expected, ignored
|
|
}()
|
|
|
|
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
|
|
t.Fatalf("post-failure cleanup did not remove keypath; stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGitAdapter_SSH_HostMismatch_NoTmpfile: rejected host mismatch must
|
|
// NOT leave an orphaned tmpfile in os.TempDir().
|
|
func TestGitAdapter_SSH_HostMismatch_NoTmpfile(t *testing.T) {
|
|
t.Setenv("TMPDIR", t.TempDir())
|
|
cred := sshCredFor(t, "github.com")
|
|
adapter := tools.AdapterFor("git")
|
|
|
|
// This assertion observes os.TempDir(), which is process-global. Keep it
|
|
// serial so other SSH lifecycle tests cannot create legitimate temp keys
|
|
// between the before/after snapshots.
|
|
before, _ := filepath.Glob(filepath.Join(os.TempDir(), "goclaw-gitkey-*"))
|
|
_, err := adapter.Prepare(context.Background(), nil, cred,
|
|
[]string{"clone", "git@gitlab.com:o/r.git"})
|
|
if err == nil {
|
|
t.Fatal("expected host mismatch error")
|
|
}
|
|
after, _ := filepath.Glob(filepath.Join(os.TempDir(), "goclaw-gitkey-*"))
|
|
if len(after) > len(before) {
|
|
t.Fatalf("orphaned tmpfile after rejected Prepare: before=%d after=%d new=%v",
|
|
len(before), len(after), after)
|
|
}
|
|
}
|
|
|
|
func falseBin() string {
|
|
if runtime.GOOS == "windows" {
|
|
// cmd.exe /c exit 1 — but `exit` is a builtin, use a guaranteed-fail call.
|
|
return "cmd"
|
|
}
|
|
return "false"
|
|
}
|