Files
2026-06-02 23:14:41 +08:00

225 lines
7.3 KiB
Go

//go:build !windows
package core
import (
"context"
"errors"
"os/exec"
"reflect"
"slices"
"strings"
"testing"
)
func TestBuildSpawnCommand_Legacy(t *testing.T) {
ctx := context.Background()
cmd := BuildSpawnCommand(ctx, SpawnOptions{}, "claude", "--version")
if cmd == nil {
t.Fatal("BuildSpawnCommand returned nil")
}
if cmd.Path == "" || !strings.HasSuffix(cmd.Args[0], "claude") {
t.Fatalf("legacy spawn: want path ending in claude, got %q (args %v)", cmd.Path, cmd.Args)
}
if len(cmd.Args) != 2 || cmd.Args[1] != "--version" {
t.Fatalf("legacy spawn: unexpected args %v", cmd.Args)
}
}
func TestBuildSpawnCommand_RunAsUser(t *testing.T) {
ctx := context.Background()
opts := SpawnOptions{
RunAsUser: "partseeker-coder",
EnvAllowlist: []string{"PGSSLROOTCERT", "PGSSLMODE"},
}
cmd := BuildSpawnCommand(ctx, opts, "claude", "--version", "-p", "hello")
if cmd == nil {
t.Fatal("BuildSpawnCommand returned nil")
}
if !strings.HasSuffix(cmd.Args[0], "sudo") {
t.Fatalf("want argv[0] ending in sudo, got %q", cmd.Args[0])
}
// Expected: sudo -n -iu partseeker-coder --preserve-env=<allow> -- claude --version -p hello
want := []string{"-n", "-iu", "partseeker-coder"}
if !reflect.DeepEqual(cmd.Args[1:4], want) {
t.Fatalf("sudo args[1:4] = %v, want %v", cmd.Args[1:4], want)
}
if !strings.HasPrefix(cmd.Args[4], "--preserve-env=") {
t.Fatalf("expected --preserve-env= at args[4], got %q", cmd.Args[4])
}
// Allowlist must include both defaults and the extensions, sorted+deduped.
preserveList := strings.TrimPrefix(cmd.Args[4], "--preserve-env=")
preserved := strings.Split(preserveList, ",")
for _, needed := range []string{"LANG", "LC_ALL", "TERM", "PGSSLROOTCERT", "PGSSLMODE"} {
if !slices.Contains(preserved, needed) {
t.Errorf("preserve-env missing %q; got %v", needed, preserved)
}
}
// PATH must NOT be in the allowlist — sudo -i rebuilds it from the
// target user's login profile, and preserving the supervisor's PATH
// would leak supervisor work dirs into the isolated session.
if slices.Contains(preserved, "PATH") {
t.Errorf("preserve-env leaked PATH; got %v", preserved)
}
if cmd.Args[5] != "--" {
t.Fatalf("args[5] = %q, want --", cmd.Args[5])
}
if cmd.Args[6] != "claude" {
t.Fatalf("args[6] = %q, want claude", cmd.Args[6])
}
if !reflect.DeepEqual(cmd.Args[7:], []string{"--version", "-p", "hello"}) {
t.Fatalf("agent args = %v, want [--version -p hello]", cmd.Args[7:])
}
}
func TestFilterEnvForSpawn_Legacy(t *testing.T) {
env := []string{"PATH=/usr/bin", "SECRET=top", "PWD=/tmp"}
got := FilterEnvForSpawn(env, SpawnOptions{})
if !reflect.DeepEqual(got, env) {
t.Fatalf("legacy mode should pass env through unchanged; got %v, want %v", got, env)
}
}
func TestFilterEnvForSpawn_RunAsUser(t *testing.T) {
env := []string{
"PATH=/usr/bin",
"LANG=en_US.UTF-8",
"SECRET=top",
"HOME=/home/supervisor",
"SUPERVISOR_CREDENTIAL=nope",
"PGSSLROOTCERT=/etc/certs/root.crt",
}
opts := SpawnOptions{
RunAsUser: "target",
EnvAllowlist: []string{"PGSSLROOTCERT"},
}
got := FilterEnvForSpawn(env, opts)
// LANG, PGSSLROOTCERT should survive; PATH, SECRET, HOME,
// SUPERVISOR_CREDENTIAL must not. PATH is deliberately dropped so
// sudo -i can rebuild it from the target user's login profile
// instead of inheriting the supervisor's PATH.
wantKept := map[string]bool{
"LANG=en_US.UTF-8": true,
"PGSSLROOTCERT=/etc/certs/root.crt": true,
}
for _, e := range got {
if !wantKept[e] {
t.Errorf("unexpected env survived filter: %q", e)
}
delete(wantKept, e)
}
for e := range wantKept {
t.Errorf("expected env missing after filter: %q", e)
}
for _, e := range got {
if strings.HasPrefix(e, "PATH=") || strings.HasPrefix(e, "SECRET=") || strings.HasPrefix(e, "HOME=") || strings.HasPrefix(e, "SUPERVISOR_CREDENTIAL=") {
t.Errorf("disallowed env leaked: %q", e)
}
}
}
// stubSudoRunner implements SudoRunner for tests.
type stubSudoRunner struct {
// script maps an argv slice (joined with \x1f) to a response.
script map[string]stubResponse
// calls records every invocation in order.
calls [][]string
}
type stubResponse struct {
out []byte
err error
}
func (s *stubSudoRunner) Run(_ context.Context, args ...string) ([]byte, error) {
s.calls = append(s.calls, append([]string{}, args...))
key := strings.Join(args, "\x1f")
if r, ok := s.script[key]; ok {
return r.out, r.err
}
return nil, errors.New("stubSudoRunner: unscripted call: " + strings.Join(args, " "))
}
func key(args ...string) string { return strings.Join(args, "\x1f") }
func TestVerifyRunAsUserCheap_Success(t *testing.T) {
ResetVerifyCache()
runner := &stubSudoRunner{
script: map[string]stubResponse{
key("-n", "-iu", "target", "--", "/usr/bin/true"): {nil, nil},
key("-n", "-iu", "target", "--", "sudo", "-n", "/usr/bin/true"): {[]byte("a password is required"), &exec.ExitError{}},
},
}
if err := VerifyRunAsUserCheap(context.Background(), runner, "target"); err != nil {
t.Fatalf("want nil, got %v", err)
}
if len(runner.calls) != 2 {
t.Fatalf("want 2 sudo calls, got %d", len(runner.calls))
}
}
func TestVerifyRunAsUserCheap_NoPasswordlessSudoToTarget(t *testing.T) {
ResetVerifyCache()
runner := &stubSudoRunner{
script: map[string]stubResponse{
key("-n", "-iu", "target", "--", "/usr/bin/true"): {[]byte("a password is required"), &exec.ExitError{}},
},
}
err := VerifyRunAsUserCheap(context.Background(), runner, "target")
if err == nil {
t.Fatal("want error, got nil")
}
if !strings.Contains(err.Error(), "passwordless sudo to user") {
t.Errorf("error = %v, want 'passwordless sudo to user' message", err)
}
}
func TestVerifyRunAsUserCheap_TargetCanEscalate(t *testing.T) {
ResetVerifyCache()
runner := &stubSudoRunner{
script: map[string]stubResponse{
key("-n", "-iu", "target", "--", "/usr/bin/true"): {nil, nil},
key("-n", "-iu", "target", "--", "sudo", "-n", "/usr/bin/true"): {nil, nil}, // BAD — escalation succeeded
},
}
err := VerifyRunAsUserCheap(context.Background(), runner, "target")
if err == nil {
t.Fatal("want error, got nil")
}
if !strings.Contains(err.Error(), "can run passwordless sudo") {
t.Errorf("error = %v, want 'can run passwordless sudo' message", err)
}
}
func TestVerifyRunAsUserCheap_EmptyUser(t *testing.T) {
ResetVerifyCache()
runner := &stubSudoRunner{script: map[string]stubResponse{}}
if err := VerifyRunAsUserCheap(context.Background(), runner, ""); err == nil {
t.Fatal("want error for empty user")
}
}
func TestVerifyRunAsUserCheap_CacheHit(t *testing.T) {
ResetVerifyCache()
runner := &stubSudoRunner{
script: map[string]stubResponse{
key("-n", "-iu", "target", "--", "/usr/bin/true"): {nil, nil},
key("-n", "-iu", "target", "--", "sudo", "-n", "/usr/bin/true"): {nil, &exec.ExitError{}},
},
}
// First call populates the cache with 2 runner calls.
if err := VerifyRunAsUserCheap(context.Background(), runner, "target"); err != nil {
t.Fatalf("first call: %v", err)
}
if len(runner.calls) != 2 {
t.Fatalf("first call: want 2 runner calls, got %d", len(runner.calls))
}
// Second call should be served from the cache — zero new runner calls.
if err := VerifyRunAsUserCheap(context.Background(), runner, "target"); err != nil {
t.Fatalf("second call: %v", err)
}
if len(runner.calls) != 2 {
t.Fatalf("cached call made runner calls; want 2 total, got %d", len(runner.calls))
}
}