Ctrl K
ComunidadGuías…

utils/reexec_linux_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//go:build linux

package utils

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"syscall"
	"testing"
	"unicode/utf8"

	keployLog "go.keploy.io/server/v3/utils/log"
	"go.uber.org/zap"
	"go.uber.org/zap/zaptest/observer"
)

func TestIsCloudReplayCmd(t *testing.T) {
	tests := []struct {
		name string
		args []string
		want bool
	}{
		{
			name: "cloud replay",
			args: []string{"keploy", "cloud", "replay"},
			want: true,
		},
		{
			name: "cloud replay with flags",
			args: []string{"keploy", "cloud", "replay", "--verbose"},
			want: true,
		},
		{
			name: "persistent flag before subcommands",
			args: []string{"keploy", "--debug", "cloud", "replay"},
			want: true,
		},
		{
			name: "persistent flag between cloud and replay",
			args: []string{"keploy", "cloud", "--debug", "replay"},
			want: true,
		},
		{
			name: "flag with value between cloud and replay",
			args: []string{"keploy", "cloud", "--config", "/tmp/cfg", "replay"},
			want: true,
		},
		{
			name: "flag with inline value between cloud and replay",
			args: []string{"keploy", "cloud", "--config=/tmp/cfg", "replay"},
			want: true,
		},
		{
			name: "cloud without replay",
			args: []string{"keploy", "cloud", "record"},
			want: false,
		},
		{
			name: "only cloud",
			args: []string{"keploy", "cloud"},
			want: false,
		},
		{
			name: "replay without cloud",
			args: []string{"keploy", "replay"},
			want: false,
		},
		{
			name: "no subcommand",
			args: []string{"keploy"},
			want: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := isCloudReplayCmd(tt.args); got != tt.want {
				t.Errorf("isCloudReplayCmd(%v) = %v, want %v", tt.args, got, tt.want)
			}
		})
	}
}

// ShouldReexecWithSudo decides whether keploy needs root, from raw argv,
// before cobra or any config file is involved. An explicit --cmd-type has to
// win here or `--cmd-type docker-run -c "make up"` starts unprivileged and
// dies much later writing /proc/sys/kernel/perf_event_paranoid (#4399).
//
// Pins the wiring, not just the argv helper: dropping the ExtractCmdTypeFromArgs
// call would leave that helper's own test green while the feature stayed broken.
func TestShouldReexecWithSudo_HonoursExplicitCmdType(t *testing.T) {
	if os.Geteuid() == 0 {
		t.Skip("runs as root; ShouldReexecWithSudo short-circuits before the cmd-type check")
	}

	cases := []struct {
		name string
		args []string
		want bool
	}{
		// The feature: a command that does not look like docker, declared as docker.
		{"explicit docker-compose on a wrapper", []string{"keploy", "record", "--cmd-type", "docker-compose", "-c", "make up"}, true},
		{"explicit docker-run on a script", []string{"keploy", "record", "--cmd-type=docker-run", "-c", "./start.sh"}, true},
		// The inverse: a docker-looking command declared native still needs no root.
		{"explicit native on a docker command", []string{"keploy", "record", "--cmd-type", "native", "-c", "docker compose up"}, false},
		// Unchanged behaviour when the flag is absent.
		{"no flag, docker command", []string{"keploy", "record", "-c", "docker compose up"}, true},
		{"no flag, plain command", []string{"keploy", "record", "-c", "python app.py"}, false},
		// An unrecognised value falls through to sniffing; ValidateFlags reports it properly later.
		{"garbage falls back to sniffing", []string{"keploy", "record", "--cmd-type", "nope", "-c", "docker compose up"}, true},
		// The camelCase alias cobra also accepts must not skip this gate.
		{"camelCase alias on a wrapper", []string{"keploy", "record", "--cmdType", "docker-compose", "-c", "make up"}, true},
		{"camelCase alias native on a docker command", []string{"keploy", "record", "--cmdType=native", "-c", "docker compose up"}, false},
	}

	orig := os.Args
	defer func() { os.Args = orig }()

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			os.Args = tc.args
			if got := ShouldReexecWithSudo(); got != tc.want {
				t.Errorf("ShouldReexecWithSudo() = %v, want %v for %v", got, tc.want, tc.args)
			}
		})
	}
}

// A Podman command in a build that cannot drive Podman is refused by
// ValidateFlags. Re-executing under sudo first would ask for a password only
// to refuse the command after it, so it must not happen, whether the kind was
// sniffed or given with --cmd-type. (A build that registers Podman re-executes
// as it does for Docker; that is tested where Podman is registered.)
func TestShouldReexecWithSudo_NotForAnEngineThisBuildCannotDrive(t *testing.T) {
	if os.Geteuid() == 0 {
		t.Skip("runs as root; ShouldReexecWithSudo short-circuits before the engine check")
	}
	orig := os.Args
	defer func() { os.Args = orig }()

	for _, args := range [][]string{
		{"keploy", "record", "-c", "podman run --name app img"},
		{"keploy", "test", "-c", "sudo podman start -a app"},
		{"keploy", "record", "-c", "podman compose up"},
		{"keploy", "record", "--cmd-type", "docker-run", "-c", "podman run --name app img"},
		{"keploy", "record", "--cmd-type", "docker-run", "-c", "sudo -E podman run --name app img"},
	} {
		os.Args = args
		if ShouldReexecWithSudo() {
			t.Errorf("ShouldReexecWithSudo() = true for %v, want false: Podman is not supported in this build", args)
		}
	}

	// Docker still re-executes.
	os.Args = []string{"keploy", "record", "-c", "docker run --name app img"}
	if !ShouldReexecWithSudo() {
		t.Error("ShouldReexecWithSudo() = false for a docker run, want true")
	}
}

// docker-start is refused before anything starts (it cannot work), so it must
// not ask for a sudo password first, detected or given.
func TestShouldReexecWithSudo_NotForDockerStart(t *testing.T) {
	if os.Geteuid() == 0 {
		t.Skip("runs as root; ShouldReexecWithSudo short-circuits")
	}
	orig := os.Args
	defer func() { os.Args = orig }()
	for _, args := range [][]string{
		{"keploy", "record", "-c", "docker start -a app"},
		{"keploy", "record", "--cmd-type", "docker-start", "-c", "docker start -a app"},
	} {
		os.Args = args
		if ShouldReexecWithSudo() {
			t.Errorf("ShouldReexecWithSudo() = true for %v: a refused command would ask for sudo first", args)
		}
	}
	os.Args = []string{"keploy", "record", "-c", "docker run --name app img"}
	if !ShouldReexecWithSudo() {
		t.Error("ShouldReexecWithSudo() = false for docker run")
	}
}

// The run goes on in the process the sudo exec starts, which keeps a log of
// its own. This process's log, a temp file, is gone by the exec: nothing would
// be left to remove it, one log left in the temp directory per Docker or cloud
// replay run that re-executes itself with sudo.
func TestReexecWithSudo_RemovesThisProcesssLogBeforeTheExec(t *testing.T) {
	bin := t.TempDir()
	sudo := filepath.Join(bin, "sudo")
	if err := os.WriteFile(sudo, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", bin)
	t.Setenv("TMPDIR", t.TempDir())
	_, f, err := keployLog.New()
	if err != nil || f == nil {
		t.Fatalf("log.New = %v, %v; want a log file", f, err)
	}
	t.Cleanup(func() {
		_ = keployLog.CloseLogFile(f)
		keployLog.TestOnlyResetSink()
	})
	prevLog := LogFile
	LogFile = f
	t.Cleanup(func() { LogFile = prevLog })

	execed, leftBehind := "", true
	prev := execProcess
	execProcess = func(path string, _ []string, _ []string) error {
		execed = path
		_, err := os.Lstat(f.Name())
		leftBehind = !os.IsNotExist(err)
		return nil
	}
	t.Cleanup(func() { execProcess = prev })

	if err := ReexecWithSudo(zap.NewNop()); err != nil {
		t.Fatalf("ReexecWithSudo = %v, want nil where its exec succeeds", err)
	}
	if execed != sudo {
		t.Fatalf("ReexecWithSudo exec'd %q, want %s", execed, sudo)
	}
	if leftBehind {
		t.Errorf("this process's log %s was still there at the exec", f.Name())
	}
}

// A re-exec that cannot happen ends the run through its caller, with an
// error, not inside ReexecWithSudo: an os.Exit there skipped every deferred
// call of the run, the removal of its temp log among them (here it would end
// this test binary). Where the exec itself fails, the log's name is gone
// already, and closing the log after it is no error.
func TestReexecWithSudo_AFailureReturnsToItsCaller(t *testing.T) {
	t.Run("no sudo on PATH", func(t *testing.T) {
		t.Setenv("PATH", t.TempDir())
		execed := false
		prev := execProcess
		execProcess = func(string, []string, []string) error { execed = true; return nil }
		t.Cleanup(func() { execProcess = prev })
		if err := ReexecWithSudo(zap.NewNop()); err == nil {
			t.Error("ReexecWithSudo = nil with no sudo on PATH, want an error")
		}
		if execed {
			t.Error("ReexecWithSudo exec'd with no sudo on PATH")
		}
	})
	t.Run("the exec fails", func(t *testing.T) {
		bin := t.TempDir()
		sudo := filepath.Join(bin, "sudo")
		if err := os.WriteFile(sudo, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
			t.Fatal(err)
		}
		t.Setenv("PATH", bin)
		t.Setenv("TMPDIR", t.TempDir())
		_, f, err := keployLog.New()
		if err != nil || f == nil {
			t.Fatalf("log.New = %v, %v; want a log file", f, err)
		}
		t.Cleanup(keployLog.TestOnlyResetSink)
		prevLog := LogFile
		LogFile = f
		t.Cleanup(func() { LogFile = prevLog })
		prev := execProcess
		execProcess = func(string, []string, []string) error { return syscall.EACCES }
		t.Cleanup(func() { execProcess = prev })

		if err := ReexecWithSudo(zap.NewNop()); !errors.Is(err, syscall.EACCES) {
			t.Errorf("ReexecWithSudo = %v, want the exec's own error", err)
		}
		if _, err := os.Lstat(f.Name()); !os.IsNotExist(err) {
			t.Errorf("this process's log %s is still there after a failed exec (lstat: %v)", f.Name(), err)
		}
		if err := keployLog.CloseLogFile(f); err != nil {
			t.Errorf("CloseLogFile after a failed exec = %v, want nil", err)
		}
	})
}

// sudo-rs (Ubuntu 26.04's sudo) ignores -E, so a re-exec through `sudo -E`
// ran keploy, and the app it runs, without the user's environment. The
// command names every variable for sudo to keep instead, which sudo and
// sudo-rs both honour, and leaves out what sudo sets itself, as -E did.
func TestSudoReexecArgs(t *testing.T) {
	t.Run("names every variable but the ones sudo sets", func(t *testing.T) {
		environ := []string{
			"FOO=foo-val", "HOME=/home/u1", "PATH=/home/u1/bin:/usr/bin",
			"USER=u1", "LOGNAME=u1", "USERNAME=u1", "SUDO_USER=stale", "SUDO_UID=4242",
			"STAMP_CREATED_AT=2026-11-04T00:00:00Z", "KEPLOY_API_KEY=s3cr3t-value",
			"A,B=comma", "FOO=second", "=no-name", "MAIL=/var/mail/u1", "EMPTY=",
			"N\xffU=not-utf-8", "OK=\xff",
		}
		argv, unkept := sudoReexecArgs(environ, "/home/u1/bin:/usr/bin", "/opt/keploy", []string{"record", "-c", "docker compose up"})
		want := []string{
			"sudo",
			"--preserve-env=FOO,HOME,STAMP_CREATED_AT,KEPLOY_API_KEY,MAIL,EMPTY,OK",
			"env", "PATH=/home/u1/bin:/usr/bin", "/opt/keploy",
			"record", "-c", "docker compose up",
		}
		if strings.Join(argv, "\x00") != strings.Join(want, "\x00") {
			t.Errorf("sudoReexecArgs =\n  %q\nwant\n  %q", argv, want)
		}
		if strings.Join(unkept, "\x00") != "A,B\x00N\xffU" {
			t.Errorf("unkept = %q, want the name with a comma and the name that is not UTF-8, [A,B N\xffU] (a value that is not UTF-8 is kept: only the name goes in the list)", unkept)
		}
		for _, a := range argv {
			if a == "-E" {
				t.Error("the command still asks for -E, which sudo-rs ignores")
			}
			if strings.Contains(a, "s3cr3t-value") || strings.Contains(a, "foo-val") {
				t.Errorf("a value is on the command line, which any user can read: %q", a)
			}
		}
	})
	t.Run("no list when nothing is left to name", func(t *testing.T) {
		argv, unkept := sudoReexecArgs([]string{"PATH=/usr/bin", "USER=u1", "SUDO_GID=7"}, "/usr/bin", "/opt/keploy", nil)
		want := []string{"sudo", "env", "PATH=/usr/bin", "/opt/keploy"}
		if strings.Join(argv, "\x00") != strings.Join(want, "\x00") || len(unkept) != 0 {
			t.Errorf("sudoReexecArgs = %q, %q; want %q, none unkept", argv, unkept, want)
		}
	})
}

// A sudo that would not keep the list is refused before anything runs:
// sudo-rs before 0.2.7 ignores it, with a warning only, and sudo before 1.8.21
// rejects it as a usage error. Any other sudo gets the command, with the
// values in the environment it execs with.
func TestReexecWithSudo_TheEnvironmentReachesSudoOrTheRunIsRefused(t *testing.T) {
	bin := t.TempDir()
	sudo := filepath.Join(bin, "sudo")
	if err := os.WriteFile(sudo, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", bin)
	t.Setenv("STAMP_CREATED_AT", "2026-11-04T00:00:00Z")
	prevLog := LogFile
	LogFile = nil
	t.Cleanup(func() { LogFile = prevLog })
	for _, tc := range []struct {
		version string
		refused string // what the refusal names; empty where sudo runs
	}{
		{version: "sudo-rs 0.2.5\n", refused: "sudo-rs 0.2.5 ignores that list"},
		{version: "sudo-rs 0.2.6\n", refused: "sudo-rs 0.2.6"},
		{version: "sudo-rs 0.2.6-1\n", refused: "implements it from 0.2.7 on), so the run would go on without your environment. Upgrade sudo-rs to 0.2.7 or later"},
		{version: "Sudo version 1.8.16\n", refused: "sudo 1.8.16 does not accept that list"},
		{version: "Sudo version 1.8.20p2\n", refused: "(sudo accepts it from 1.8.21 on). Upgrade sudo to 1.8.21 or later"},
		{version: "sudo-rs 0.2.7\n"},
		{version: "sudo-rs 0.2.13-0ubuntu1.2\n"},
		{version: "Sudo version 1.8.21p2\n"},
		{version: "Sudo version 1.9.15p5\nSudoers policy plugin version 1.9.15p5\n"},
		{version: ""},
	} {
		t.Run(strings.TrimSpace(tc.version), func(t *testing.T) {
			prevVersion := sudoVersionOf
			sudoVersionOf = func(context.Context) string { return tc.version }
			t.Cleanup(func() { sudoVersionOf = prevVersion })
			var execed string
			var argv, envv []string
			prev := execProcess
			execProcess = func(path string, a, e []string) error { execed, argv, envv = path, a, e; return nil }
			t.Cleanup(func() { execProcess = prev })

			err := ReexecWithSudo(zap.NewNop())
			if tc.refused != "" {
				if err == nil || !strings.Contains(err.Error(), tc.refused) {
					t.Fatalf("ReexecWithSudo = %v, want a refusal naming %q", err, tc.refused)
				}
				if execed != "" {
					t.Errorf("ReexecWithSudo exec'd %s %q through a sudo it refused", execed, argv)
				}
				return
			}
			if err != nil {
				t.Fatalf("ReexecWithSudo = %v, want the exec", err)
			}
			if execed != sudo || len(argv) < 2 || !strings.HasPrefix(argv[1], "--preserve-env=") ||
				!slices.Contains(strings.Split(strings.TrimPrefix(argv[1], "--preserve-env="), ","), "STAMP_CREATED_AT") {
				t.Errorf("ReexecWithSudo exec'd %s %q, want %s --preserve-env=…,STAMP_CREATED_AT,… env PATH=…", execed, argv, sudo)
			}
			if !slices.Contains(envv, "STAMP_CREATED_AT=2026-11-04T00:00:00Z") {
				t.Error("the environment sudo is exec'd with has no STAMP_CREATED_AT: the list names a variable sudo does not get")
			}
		})
	}
}

// A name with a comma cannot be in the --preserve-env list: the run goes on
// without that variable, and says which it is. A run with no such name says
// nothing.
func TestReexecWithSudo_SaysWhichVariablesTheListCannotCarry(t *testing.T) {
	bin := t.TempDir()
	if err := os.WriteFile(filepath.Join(bin, "sudo"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", bin)
	prevLog := LogFile
	LogFile = nil
	t.Cleanup(func() { LogFile = prevLog })
	prevVersion := sudoVersionOf
	sudoVersionOf = func(context.Context) string { return "sudo-rs 0.2.13\n" }
	t.Cleanup(func() { sudoVersionOf = prevVersion })
	prev := execProcess
	execProcess = func(string, []string, []string) error { return nil }
	t.Cleanup(func() { execProcess = prev })
	for _, kv := range os.Environ() {
		if name, _, _ := strings.Cut(kv, "="); strings.Contains(name, ",") || !utf8.ValidString(name) {
			t.Skipf("this test's own environment has %q, a name the list cannot carry", name)
		}
	}
	warnings := func() []observer.LoggedEntry {
		core, logs := observer.New(zap.WarnLevel)
		if err := ReexecWithSudo(zap.New(core)); err != nil {
			t.Fatalf("ReexecWithSudo = %v, want the exec", err)
		}
		return logs.All()
	}

	if w := warnings(); len(w) != 0 {
		t.Errorf("ReexecWithSudo warned %q with no name the list cannot carry", w[0].Message)
	}
	t.Setenv("A,B", "comma")
	w := warnings()
	if len(w) != 1 || !strings.Contains(w[0].Message, "comma") || !slices.Contains(w[0].ContextMap()["variables"].([]interface{}), any("A,B")) {
		t.Errorf("ReexecWithSudo warned %v, want one warning that names A,B", w)
	}
	// sudo-rs refuses a whole command line that is not UTF-8.
	t.Setenv("N\xffU", "not-utf-8")
	w = warnings()
	if len(w) != 1 || !strings.Contains(w[0].Message, "not UTF-8") || len(w[0].ContextMap()["variables"].([]interface{})) != 2 {
		t.Errorf("ReexecWithSudo warned %v, want one warning that names A,B and the name that is not UTF-8", w)
	}
}

func TestSudoPreserveEnvListGap(t *testing.T) {
	for _, tc := range []struct {
		version string
		want    string // the gap's String; empty for none
		ignored bool
	}{
		{version: "sudo-rs 0.2.6\n", want: "sudo-rs 0.2.6", ignored: true},
		{version: "sudo-rs 0.1.9\n", want: "sudo-rs 0.1.9", ignored: true},
		{version: "sudo-rs 0.2.7\n"},
		{version: "sudo-rs 1.0.0\n"},
		{version: "Sudo version 1.8.20p2\n", want: "sudo 1.8.20"},
		{version: "Sudo version 1.7.30\n", want: "sudo 1.7.30"},
		{version: "Sudo version 1.8.21\n"},
		{version: "Sudo version 2.0.0\n"},
		{version: "sudo: unable to resolve host box\nSudo version 1.8.20\n", want: "sudo 1.8.20"},
		{version: "sudo: unknown option --version\n"},
		// A version past what an int holds names none: let through.
		{version: "sudo-rs 0.1.99999999999999999999\n"},
	} {
		gap, ok := SudoPreserveEnvListGap(tc.version)
		if got := map[bool]string{true: gap.String()}[ok]; got != tc.want || (ok && gap.Ignored != tc.ignored) {
			t.Errorf("SudoPreserveEnvListGap(%q) = %v %v (ignored %v), want %q (ignored %v)", tc.version, gap, ok, gap.Ignored, tc.want, tc.ignored)
		}
	}
}