package exec import ( "context" "errors" "io" "log/slog" "os" "os/exec" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // discardLogger returns a logger that writes nowhere, keeping test output clean. func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // requireBin skips the test when name is not on PATH. func requireBin(t *testing.T, name string) { t.Helper() if _, err := exec.LookPath(name); err != nil { t.Skipf("%s not available: %v", name, err) } } // enabledCfg builds an enabled sandbox config with a single allowlisted command // and optional allowed roots. func enabledCfg(command string, roots ...string) config.ExecSandbox { return config.ExecSandbox{ Enabled: true, CommandList: []string{command}, AllowedRoots: roots, } } func newEnabledExecutor(t *testing.T, cfg config.ExecSandbox) *TerminalExecutor { t.Helper() ex, err := New(cfg, discardLogger()) if err != nil { t.Fatalf("New: %v", err) } return ex } func TestTerminal_DefaultDisabled_Refuses(t *testing.T) { t.Parallel() ex, err := New(config.ExecSandbox{Enabled: false}, discardLogger()) if err != nil { t.Fatalf("New: %v", err) } _, err = ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}}) if !errors.Is(err, ErrExecutionDisabled) { t.Fatalf("err = %v, want ErrExecutionDisabled", err) } } func TestTerminal_NewNilLogger(t *testing.T) { t.Parallel() if _, err := New(config.ExecSandbox{}, nil); !errors.Is(err, ErrNilLogger) { t.Fatalf("err = %v, want ErrNilLogger", err) } } func TestTerminal_NewEnabledNeedsRoot(t *testing.T) { t.Parallel() _, err := New(enabledCfg("echo"), discardLogger()) if !errors.Is(err, ErrNoAllowedRoot) { t.Fatalf("err = %v, want ErrNoAllowedRoot", err) } } func TestTerminal_Allowlist_Allowed(t *testing.T) { t.Parallel() requireBin(t, "echo") ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir())) res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hello"}}) if err != nil { t.Fatalf("Execute: %v", err) } if res.ExitCode != 0 { t.Errorf("ExitCode = %d, want 0", res.ExitCode) } if !strings.Contains(res.Stdout, "hello") { t.Errorf("Stdout = %q, want contain hello", res.Stdout) } if res.Truncated { t.Errorf("Truncated = true, want false") } if res.PID <= 0 { t.Errorf("PID = %d, want > 0", res.PID) } if res.StartedAt.IsZero() || res.EndedAt.IsZero() { t.Errorf("timestamps must be set") } } func TestTerminal_Allowlist_Denied(t *testing.T) { t.Parallel() requireBin(t, "echo") // pwd is allowlisted; echo is not. ex := newEnabledExecutor(t, enabledCfg("pwd", t.TempDir())) _, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}}) if !errors.Is(err, ErrCommandNotAllowed) { t.Fatalf("err = %v, want ErrCommandNotAllowed", err) } } func TestTerminal_Injection_Rejected(t *testing.T) { t.Parallel() requireBin(t, "echo") ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir())) cases := []struct { name string arg string }{ {"semicolon", "a;b"}, {"pipe", "a|b"}, {"ampersand", "a&b"}, {"subshell", "$(rm -rf /)"}, {"backtick", "`id`"}, {"redirect-out", ">out"}, {"redirect-in", " 3*time.Second { t.Errorf("timeout took %v, want ~200ms (process not killed)", elapsed) } } func TestTerminal_Output_Truncated(t *testing.T) { t.Parallel() requireBin(t, "head") ex := newEnabledExecutor(t, enabledCfg("head", t.TempDir())) res, err := ex.Execute(context.Background(), ExecRequest{ Command: []string{"head", "-c", "2000000", "/dev/zero"}, MaxOutputBytes: 100, }) if err != nil { t.Fatalf("Execute: %v", err) } if !res.Truncated { t.Errorf("Truncated = false, want true") } if len(res.Stdout) != 100 { t.Errorf("len(Stdout) = %d, want 100", len(res.Stdout)) } } func TestTerminal_NonZeroExit_ReportedNotError(t *testing.T) { t.Parallel() requireBin(t, "false") ex := newEnabledExecutor(t, enabledCfg("false", t.TempDir())) res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"false"}}) if err != nil { t.Fatalf("Execute: %v", err) } if res.ExitCode != 1 { t.Errorf("ExitCode = %d, want 1", res.ExitCode) } } func TestTerminal_Stdin_Passthrough(t *testing.T) { t.Parallel() requireBin(t, "cat") ex := newEnabledExecutor(t, enabledCfg("cat", t.TempDir())) res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"cat"}, Stdin: []byte("ping")}) if err != nil { t.Fatalf("Execute: %v", err) } if res.Stdout != "ping" { t.Errorf("Stdout = %q, want ping", res.Stdout) } } func TestTerminal_Env_Whitelist(t *testing.T) { t.Parallel() requireBin(t, "env") root := t.TempDir() // No allowlist: any injected variable is refused before launching. noEnv := newEnabledExecutor(t, enabledCfg("env", root)) _, err := noEnv.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}}) if !errors.Is(err, ErrEnvNotAllowed) { t.Fatalf("no-allowlist err = %v, want ErrEnvNotAllowed", err) } // Allowlisted variable is applied. cfg := enabledCfg("env", root) cfg.AllowedEnv = []string{"FOO"} ex := newEnabledExecutor(t, cfg) res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}}) if err != nil { t.Fatalf("Execute: %v", err) } if !strings.Contains(res.Stdout, "FOO=bar") { t.Errorf("Stdout = %q, want contain FOO=bar", res.Stdout) } } func TestTerminal_EmptyCommand(t *testing.T) { t.Parallel() ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir())) _, err := ex.Execute(context.Background(), ExecRequest{}) if !errors.Is(err, ErrEmptyCommand) { t.Fatalf("err = %v, want ErrEmptyCommand", err) } }