diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 35af5446a3..777705568a 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -11,8 +11,8 @@ import ( "log" "os" "os/exec" + "path" "runtime" - "strings" "sync" "syscall" "time" @@ -152,11 +152,40 @@ func (pp *PipePty) WriteString(s string) (n int, err error) { return pp.Write([]byte(s)) } +// describeForLog builds a log-safe summary of a shell launch: shell types, +// executable, argc, and env var NAMES only (never values) — swap tokens and +// JWTs must never reach a log line, so this is the only helper allowed to +// summarize a launch for logging, and it never accepts the fully-assembled +// (env-prefixed) command string. +func describeForLog(outerShellType string, innerShellType string, shellPath string, shellOpts []string, env map[string]string) string { + envKeys := make([]string, 0, len(env)) + for k := range env { + envKeys = append(envKeys, k) + } + return fmt.Sprintf("outerShell=%s innerShell=%s exe=%q argc=%d envKeys=%v", outerShellType, innerShellType, shellPath, len(shellOpts)+1, envKeys) +} + +// wslNoWshArgv builds the wsl.exe argv for the no-wsh fallback launch. When +// cmdStr is non-empty, the requested command is executed via a plain POSIX +// "sh -c" inside the distro; cmdStr is passed as a single argv element (via +// exec.Command's structured argv, never joined into a string), so no +// quoting/flattening is needed or possible here. When cmdStr is empty, WSL's +// own default interactive shell is launched instead — this is what the +// no-wsh fallback did unconditionally before the fix, silently ignoring any +// requested cmdStr. +func wslNoWshArgv(distroName string, cmdStr string) []string { + if cmdStr != "" { + return []string{"wsl.exe", "~", "-d", distroName, "--", "sh", "-c", cmdStr} + } + return []string{"wsl.exe", "~", "-d", distroName} +} + func StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) { client := conn.GetClient() conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProcNoWsh)") - ecmd := exec.Command("wsl.exe", "~", "-d", client.Name()) + argv := wslNoWshArgv(client.Name(), cmdStr) + ecmd := exec.Command(argv[0], argv[1:]...) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows @@ -186,6 +215,9 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st return nil, fmt.Errorf("unable to obtain client info: %w", err) } log.Printf("client info collected: %+#v", remoteInfo) + if remoteInfo.HomeDir == "" { + return nil, fmt.Errorf("unable to obtain home directory from remote machine") + } var shellPath string if cmdOpts.ShellPath != "" { conn.Infof(ctx, "using shell path from command opts: %s\n", cmdOpts.ShellPath) @@ -205,7 +237,6 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st shellPath = "/bin/bash" } var shellOpts []string - var cmdCombined string log.Printf("detected shell %q for conn %q\n", shellPath, conn.GetName()) err = wshclient.RemoteInstallRcFilesCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) @@ -215,28 +246,29 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st } shellOpts = append(shellOpts, cmdOpts.ShellOpts...) shellType := shellutil.GetShellTypeFromShellPath(shellPath) + // The command reaching WSL is always relayed through a POSIX "sh -c" + // wrapper (see the exec.Command call below), so the outer shell is + // always POSIX regardless of the inner shellType we're launching. + outerShellType := shellutil.ShellType_unknown conn.Infof(ctx, "detected shell type: %s\n", shellType) - conn.Debugf(ctx, "cmdStr: %q\n", cmdStr) if cmdStr == "" { /* transform command in order to inject environment vars */ if shellType == shellutil.ShellType_bash { // add --rcfile // cant set -l or -i with --rcfile - bashPath := fmt.Sprintf("~/.waveterm/%s/.bashrc", shellutil.BashIntegrationDir) + bashPath := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.BashIntegrationDir, ".bashrc") shellOpts = append(shellOpts, "--rcfile", bashPath) } else if shellType == shellutil.ShellType_fish { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } // source the wave.fish file - waveFishPath := fmt.Sprintf("~/.waveterm/%s/wave.fish", shellutil.FishIntegrationDir) - carg := fmt.Sprintf(`"source %s"`, waveFishPath) + waveFishPath := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.FishIntegrationDir, "wave.fish") + carg := fmt.Sprintf("source %s", shellutil.HardQuoteFish(waveFishPath)) shellOpts = append(shellOpts, "-C", carg) } else if shellType == shellutil.ShellType_pwsh { - pwshPath := fmt.Sprintf("~/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir) - // powershell is weird about quoted path executables and requires an ampersand first - shellPath = "& " + shellPath + pwshPath := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.PwshIntegrationDir, "wavepwsh.ps1") shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath) } else { if cmdOpts.Login { @@ -247,33 +279,30 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st } // zdotdir setting moved to after session is created } - cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } else { - // TODO check quoting of cmdStr shellOpts = append(shellOpts, "-c", cmdStr) - cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } - conn.Infof(ctx, "starting shell, using command: %s\n", cmdCombined) + cmdCombined := shellutil.SerializeCommandForShell(outerShellType, append([]string{shellPath}, shellOpts...)) conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProc)\n") + env := make(map[string]string) if shellType == shellutil.ShellType_zsh { - zshDir := fmt.Sprintf("~/.waveterm/%s", shellutil.ZshIntegrationDir) + zshDir := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.ZshIntegrationDir) conn.Infof(ctx, "setting ZDOTDIR to %s\n", zshDir) - cmdCombined = fmt.Sprintf(`ZDOTDIR=%s %s`, zshDir, cmdCombined) + env["ZDOTDIR"] = zshDir } packedToken, err := cmdOpts.SwapToken.PackForClient() if err != nil { conn.Infof(ctx, "error packing swap token: %v", err) } else { - conn.Debugf(ctx, "packed swaptoken %s\n", packedToken) - cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveSwapTokenVarName, packedToken, cmdCombined) + env[wavebase.WaveSwapTokenVarName] = packedToken } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { - conn.Debugf(ctx, "adding JWT token to environment\n") - cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined) + env[wavebase.WaveJwtTokenVarName] = jwtToken } - log.Printf("full combined command: %s", cmdCombined) + conn.Infof(ctx, "starting shell: %s\n", describeForLog(outerShellType, shellType, shellPath, shellOpts, env)) + cmdCombined = shellutil.PrefixEnvAssignmentsForShell(outerShellType, env, cmdCombined) ecmd := exec.Command("wsl.exe", "~", "-d", client.Name(), "--", "sh", "-c", cmdCombined) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows @@ -325,8 +354,17 @@ func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, c session.Stderr = remoteStdoutWrite session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) - sessionWrap := MakeSessionWrap(session, "", pipePty) - err = session.Shell() + sessionWrap := MakeSessionWrap(session, cmdStr, pipePty) + if cmdStr != "" { + // No wsh installed on this remote, so we cannot detect its shell + // type or home dir to build an integration command line. Send + // cmdStr as-is, exactly as a plain "ssh host cmdStr" invocation + // would — this is the one raw string handed to the SSH exec + // channel, and it is never reconstructed via string-join here. + err = sessionWrap.Start() + } else { + err = session.Shell() + } if err != nil { pipePty.Close() return nil, err @@ -368,33 +406,33 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize shellPath = "/bin/bash" } var shellOpts []string - var cmdCombined string log.Printf("detected shell %q for conn %q\n", shellPath, conn.GetName()) shellOpts = append(shellOpts, cmdOpts.ShellOpts...) shellType := shellutil.GetShellTypeFromShellPath(shellPath) + // The outer shell is whatever shell the remote sshd invokes to run our + // raw exec-request string — that's the remote's login shell, which may + // differ from shellPath (the shell we're asking to launch as the + // session's inner shell). + outerShellType := shellutil.GetShellTypeFromShellPath(remoteInfo.Shell) conn.Infof(logCtx, "detected shell type: %s\n", shellType) - conn.Infof(logCtx, "swaptoken: %s\n", cmdOpts.SwapToken.Token) - conn.Debugf(logCtx, "cmdStr: %q\n", cmdStr) if cmdStr == "" { /* transform command in order to inject environment vars */ if shellType == shellutil.ShellType_bash { // add --rcfile // cant set -l or -i with --rcfile - bashPath := fmt.Sprintf("%s/.waveterm/%s/.bashrc", remoteInfo.HomeDir, shellutil.BashIntegrationDir) + bashPath := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.BashIntegrationDir, ".bashrc") shellOpts = append(shellOpts, "--rcfile", bashPath) } else if shellType == shellutil.ShellType_fish { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } // source the wave.fish file - waveFishPath := fmt.Sprintf("%s/.waveterm/%s/wave.fish", remoteInfo.HomeDir, shellutil.FishIntegrationDir) - carg := fmt.Sprintf(`"source %s"`, waveFishPath) + waveFishPath := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.FishIntegrationDir, "wave.fish") + carg := fmt.Sprintf("source %s", shellutil.HardQuoteFish(waveFishPath)) shellOpts = append(shellOpts, "-C", carg) } else if shellType == shellutil.ShellType_pwsh { - pwshPath := fmt.Sprintf("%s/.waveterm/%s/wavepwsh.ps1", remoteInfo.HomeDir, shellutil.PwshIntegrationDir) - // powershell is weird about quoted path executables and requires an ampersand first - shellPath = "& " + shellPath + pwshPath := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.PwshIntegrationDir, "wavepwsh.ps1") shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath) } else { if cmdOpts.Login { @@ -405,13 +443,10 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize } // zdotdir setting moved to after session is created } - cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } else { - // TODO check quoting of cmdStr shellOpts = append(shellOpts, "-c", cmdStr) - cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } - conn.Infof(logCtx, "starting shell, using command: %s\n", cmdCombined) + cmdCombined := shellutil.SerializeCommandForShell(outerShellType, append([]string{shellPath}, shellOpts...)) conn.Infof(logCtx, "SSH-NEWSESSION (StartRemoteShellProc)\n") session, err := client.NewSession() if err != nil { @@ -441,23 +476,24 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize session.Stdin = remoteStdinRead session.Stdout = remoteStdoutWrite session.Stderr = remoteStdoutWrite + env := make(map[string]string) if shellType == shellutil.ShellType_zsh { - zshDir := fmt.Sprintf("~/.waveterm/%s", shellutil.ZshIntegrationDir) + zshDir := path.Join(remoteInfo.HomeDir, ".waveterm", shellutil.ZshIntegrationDir) conn.Infof(logCtx, "setting ZDOTDIR to %s\n", zshDir) - cmdCombined = fmt.Sprintf(`ZDOTDIR=%s %s`, zshDir, cmdCombined) + env["ZDOTDIR"] = zshDir } packedToken, err := cmdOpts.SwapToken.PackForClient() if err != nil { conn.Infof(logCtx, "error packing swap token: %v", err) } else { - conn.Debugf(logCtx, "packed swaptoken %s\n", packedToken) - cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveSwapTokenVarName, packedToken, cmdCombined) + env[wavebase.WaveSwapTokenVarName] = packedToken } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { - conn.Debugf(logCtx, "adding JWT token to environment\n") - cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined) + env[wavebase.WaveJwtTokenVarName] = jwtToken } + conn.Infof(logCtx, "starting shell: %s\n", describeForLog(outerShellType, shellType, shellPath, shellOpts, env)) + cmdCombined = shellutil.PrefixEnvAssignmentsForShell(outerShellType, env, cmdCombined) shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) sessionWrap := MakeSessionWrap(session, cmdCombined, pipePty) @@ -503,7 +539,6 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w shellOpts = append(shellOpts, cmdOpts.ShellOpts...) shellType := shellutil.GetShellTypeFromShellPath(shellPath) conn.Infof(logCtx, "detected shell type: %s\n", shellType) - conn.Debugf(logCtx, "cmdStr: %q\n", cmdStr) if cmdStr == "" { if shellType == shellutil.ShellType_bash { @@ -530,7 +565,6 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w } else { shellOpts = append(shellOpts, "-c", cmdStr) } - conn.Infof(logCtx, "starting shell job, using command: %s %s\n", shellPath, strings.Join(shellOpts, " ")) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows @@ -552,16 +586,18 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w if err != nil { conn.Infof(logCtx, "error packing swap token: %v", err) } else { - conn.Debugf(logCtx, "packed swaptoken %s\n", packedToken) env[wavebase.WaveSwapTokenVarName] = packedToken } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { - conn.Debugf(logCtx, "adding JWT token to environment\n") env[wavebase.WaveJwtTokenVarName] = jwtToken } shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) } + // StartRemoteShellJob dispatches via structured Args (jobParams.Args + // below), never a flattened shell string, so there is no separate outer + // shell to reparse it - shellType doubles as both fields here. + conn.Infof(logCtx, "starting shell job: %s\n", describeForLog(shellType, shellType, shellPath, shellOpts, env)) jobParams := jobcontroller.StartJobParams{ ConnName: conn.GetName(), @@ -634,7 +670,6 @@ func StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdS if err != nil { blocklogger.Infof(logCtx, "error packing swap token: %v", err) } else { - blocklogger.Debugf(logCtx, "packed swaptoken %s\n", packedToken) shellutil.UpdateCmdEnv(ecmd, map[string]string{wavebase.WaveSwapTokenVarName: packedToken}) } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] diff --git a/pkg/shellexec/shellexec_test.go b/pkg/shellexec/shellexec_test.go new file mode 100644 index 0000000000..7d7f7527cf --- /dev/null +++ b/pkg/shellexec/shellexec_test.go @@ -0,0 +1,126 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +package shellexec + +import ( + "bytes" + "strings" + "testing" + + "github.com/wavetermdev/waveterm/pkg/util/shellutil" +) + +func TestWslNoWshArgv(t *testing.T) { + tests := []struct { + name string + distroName string + cmdStr string + want []string + }{ + { + name: "empty cmdStr launches default interactive shell", + distroName: "Ubuntu", + cmdStr: "", + want: []string{"wsl.exe", "~", "-d", "Ubuntu"}, + }, + { + name: "non-empty cmdStr is passed as one argv element to sh -c", + distroName: "Ubuntu", + cmdStr: "tmux attach -t cc-pp-gatefix", + want: []string{"wsl.exe", "~", "-d", "Ubuntu", "--", "sh", "-c", "tmux attach -t cc-pp-gatefix"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wslNoWshArgv(tt.distroName, tt.cmdStr) + if len(got) != len(tt.want) { + t.Fatalf("wslNoWshArgv(%q, %q) = %v, want %v", tt.distroName, tt.cmdStr, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("wslNoWshArgv(%q, %q)[%d] = %q, want %q", tt.distroName, tt.cmdStr, i, got[i], tt.want[i]) + } + } + }) + } +} + +// TestNoWsh_SessionWrapDeliversCmdStrOverRealSSH covers the SSH no-wsh +// fallback fix (StartRemoteShellProcNoWsh): before the fix, cmdStr was +// always ignored and the fallback unconditionally called session.Shell(), +// silently dropping any requested command. The fix makes it call +// sessionWrap.Start(cmdStr) whenever cmdStr is non-empty. SSHConn cannot be +// constructed outside the conncontroller package (unexported lock fields), +// so this exercises the exact SessionWrap.Start/session.Shell branch +// StartRemoteShellProcNoWsh takes, over a real SSH session, which is the +// only externally-observable effect of that function's logic. +func TestNoWsh_SessionWrapDeliversCmdStrOverRealSSH(t *testing.T) { + addr, closeFn := startTestSSHServer(t) + defer closeFn() + client := dialTestSSH(t, addr) + defer client.Close() + + cmdStr := "echo marker-a marker-b" + session, err := client.NewSession() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + defer session.Close() + + // Mirrors StartRemoteShellProcNoWsh's fixed branch: cmdStr != "" -> use + // MakeSessionWrap + sessionWrap.Start(cmdStr). Exercise SessionWrap.Start + // itself (not session.Output directly), since that's the actual + // production call path and Output/Shell would both pass a test that + // regressed to ignoring StartCmd. + var outBuf bytes.Buffer + session.Stdout = &outBuf + sessionWrap := MakeSessionWrap(session, cmdStr, nil) + if sessionWrap.StartCmd != cmdStr { + t.Fatalf("SessionWrap.StartCmd = %q, want %q", sessionWrap.StartCmd, cmdStr) + } + if err := sessionWrap.Start(); err != nil { + t.Fatalf("SessionWrap.Start failed: %v", err) + } + if err := session.Wait(); err != nil { + t.Fatalf("session.Wait failed: %v", err) + } + if got := strings.TrimSpace(outBuf.String()); got != "marker-a marker-b" { + t.Fatalf("got %q, want %q", got, "marker-a marker-b") + } +} + +// TestDescribeForLog_NeverLeaksSecrets is the log-redaction regression test: +// describeForLog must never emit a swap token, packed token, or JWT VALUE — +// only shell types, the executable, argc, and env var key NAMES. +func TestDescribeForLog_NeverLeaksSecrets(t *testing.T) { + rawToken := "super-secret-raw-token-0123456789" + packedToken := "cGFja2VkLXNlY3JldC10b2tlbi1hYmNkZWY=" // base64, but still a secret + jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNyZXQifQ.c2lnbmF0dXJl" + + env := map[string]string{ + "WAVETERM_SWAPTOKEN": packedToken, + "WAVETERM_JWT": jwt, + "ZDOTDIR": "/home/user/.waveterm/shell/zsh", + } + shellOpts := []string{"-c", "some command containing " + rawToken} + + logLine := describeForLog(shellutil.ShellType_bash, shellutil.ShellType_zsh, "/bin/zsh", shellOpts, env) + + for _, secret := range []string{rawToken, packedToken, jwt} { + if strings.Contains(logLine, secret) { + t.Fatalf("describeForLog leaked a secret value into the log line: %q contains %q", logLine, secret) + } + } + // Env var NAMES are fine (and expected) to appear. + for k := range env { + if !strings.Contains(logLine, k) { + t.Fatalf("describeForLog dropped env key name %q entirely; log line: %q", k, logLine) + } + } + // The raw shell command text (which could itself embed a secret, as in + // this test) must never appear either — describeForLog must never be + // handed or echo the fully-assembled, env-prefixed command string. + if strings.Contains(logLine, "some command containing") { + t.Fatalf("describeForLog echoed shellOpts content verbatim; log line: %q", logLine) + } +} diff --git a/pkg/shellexec/sshintegration_test.go b/pkg/shellexec/sshintegration_test.go new file mode 100644 index 0000000000..07d2ffeab7 --- /dev/null +++ b/pkg/shellexec/sshintegration_test.go @@ -0,0 +1,228 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +package shellexec + +import ( + "crypto/ed25519" + "crypto/rand" + "net" + "os/exec" + "strings" + "testing" + + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "golang.org/x/crypto/ssh" +) + +// startTestSSHServer spins up a minimal, in-process SSH server that accepts +// password auth (any credentials) and, for each "exec" channel request, runs +// the requested command line via the real system `sh -c` — exactly the way +// OpenSSH's sshd invokes a non-interactive exec payload against the +// connecting user's shell. This exercises the actual golang.org/x/crypto/ssh +// wire protocol (the same client Session type SessionWrap.Start drives in +// production), not a mock, without depending on a system sshd install. +func startTestSSHServer(t *testing.T) (addr string, closeFn func()) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate host key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("failed to create signer: %v", err) + } + config := &ssh.ServerConfig{ + PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + return nil, nil // accept anything + }, + } + config.AddHostKey(signer) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + + done := make(chan struct{}) + go func() { + for { + nConn, err := listener.Accept() + if err != nil { + return + } + go handleTestSSHConn(nConn, config) + } + }() + + return listener.Addr().String(), func() { + listener.Close() + close(done) + } +} + +func handleTestSSHConn(nConn net.Conn, config *ssh.ServerConfig) { + sconn, chans, reqs, err := ssh.NewServerConn(nConn, config) + if err != nil { + return + } + defer sconn.Close() + go ssh.DiscardRequests(reqs) + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unsupported channel type") + continue + } + channel, requests, err := newChannel.Accept() + if err != nil { + continue + } + go func() { + defer channel.Close() + for req := range requests { + if req.Type != "exec" { + if req.WantReply { + req.Reply(false, nil) + } + continue + } + // exec payload is a length-prefixed string per RFC 4254 6.5 + var payload struct{ Command string } + ssh.Unmarshal(req.Payload, &payload) + if req.WantReply { + req.Reply(true, nil) + } + cmd := exec.Command("sh", "-c", payload.Command) + cmd.Stdout = channel + cmd.Stderr = channel.Stderr() + exitCode := 0 + if runErr := cmd.Run(); runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + } + channel.SendRequest("exit-status", false, ssh.Marshal(struct{ ExitStatus uint32 }{uint32(exitCode)})) + return + } + }() + } +} + +func dialTestSSH(t *testing.T, addr string) *ssh.Client { + t.Helper() + client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{ + User: "testuser", + Auth: []ssh.AuthMethod{ssh.Password("anything")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("failed to dial test ssh server: %v", err) + } + return client +} + +// TestSSHIntegration_OldConstructionFailsOverRealSSH reproduces the exact +// original bug over a REAL (in-process, but wire-protocol-real) SSH exec +// channel: shellOpts=["-c","echo marker-a marker-b"] flattened with a naive +// unquoted join loses the "-c" argument boundary, so the marker words never +// appear in the command's output. +func TestSSHIntegration_OldConstructionFailsOverRealSSH(t *testing.T) { + addr, closeFn := startTestSSHServer(t) + defer closeFn() + client := dialTestSSH(t, addr) + defer client.Close() + + shellPath := "/bin/bash" + shellOpts := []string{"-c", "echo marker-a marker-b"} + oldCmdCombined := shellPath + " " + strings.Join(shellOpts, " ") + + session, err := client.NewSession() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + defer session.Close() + out, err := session.Output(oldCmdCombined) + got := strings.TrimSpace(string(out)) + if err == nil && got == "marker-a marker-b" { + t.Fatalf("expected the old unquoted construction to fail over real SSH, but it produced the correct output %q", got) + } +} + +// TestSSHIntegration_NewConstructionPreservesArgvOverRealSSH proves the fix: +// the same shellOpts, serialized via SerializeCommandForShell, arrives +// intact through a real SSH exec channel. +func TestSSHIntegration_NewConstructionPreservesArgvOverRealSSH(t *testing.T) { + addr, closeFn := startTestSSHServer(t) + defer closeFn() + client := dialTestSSH(t, addr) + defer client.Close() + + shellPath := "/bin/bash" + shellOpts := []string{"-c", "echo marker-a marker-b"} + newCmdCombined := shellutil.SerializeCommandForShell(shellutil.ShellType_unknown, append([]string{shellPath}, shellOpts...)) + + session, err := client.NewSession() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + defer session.Close() + out, err := session.Output(newCmdCombined) + if err != nil { + t.Fatalf("session.Output failed: %v (cmd=%q)", err, newCmdCombined) + } + got := strings.TrimSpace(string(out)) + if got != "marker-a marker-b" { + t.Fatalf("cmd=%q: got output %q, want %q", newCmdCombined, got, "marker-a marker-b") + } +} + +// TestSSHIntegration_ArbitraryCmdStrOverRealSSH exercises a cmdStr shape +// close to real usage (a wsh-run-style multi-word remote command with a +// hyphenated argument, e.g. `wsh run -- tmux attach -t cc-pp-gatefix`), +// proving the full shellPath+["-c",cmdStr] construction survives a real SSH +// round trip. +func TestSSHIntegration_ArbitraryCmdStrOverRealSSH(t *testing.T) { + addr, closeFn := startTestSSHServer(t) + defer closeFn() + client := dialTestSSH(t, addr) + defer client.Close() + + cases := []string{ + "echo marker-a marker-b", + `echo "quoted marker" 'single quoted'`, + "echo dollar-marker: $HOME literal", + "printf 'no-newline-marker'", + } + for _, cmdStr := range cases { + t.Run(cmdStr, func(t *testing.T) { + shellOpts := []string{"-c", cmdStr} + cmdCombined := shellutil.SerializeCommandForShell(shellutil.ShellType_unknown, append([]string{"/bin/bash"}, shellOpts...)) + + session, err := client.NewSession() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + defer session.Close() + gotOut, err := session.Output(cmdCombined) + if err != nil { + t.Fatalf("session.Output failed: %v (cmd=%q)", err, cmdCombined) + } + + wantSession, err := client.NewSession() + if err != nil { + t.Fatalf("failed to create comparison session: %v", err) + } + defer wantSession.Close() + // Ground truth: running cmdStr directly (as bash -c would) must + // match exactly what our serialized construction produced. + wantOut, err := wantSession.Output("bash -c " + shellutil.HardQuote(cmdStr)) + if err != nil { + t.Fatalf("failed to compute ground truth: %v", err) + } + if string(gotOut) != string(wantOut) { + t.Fatalf("cmd=%q: got %q, want %q", cmdCombined, gotOut, wantOut) + } + }) + } +} diff --git a/pkg/shellexec/tmuxe2e_test.go b/pkg/shellexec/tmuxe2e_test.go new file mode 100644 index 0000000000..453417b22b --- /dev/null +++ b/pkg/shellexec/tmuxe2e_test.go @@ -0,0 +1,132 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +package shellexec + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/shellutil" +) + +// TestTmuxE2E_QuotingFixOverRealSSH is the tmux-shaped end-to-end regression +// test for the bug that motivated this fix: `wsh run -- tmux attach -t +// ` (and similarly, any multi-word tmux subcommand) sent over SSH. +// It drives a REAL tmux server (on a throwaway socket under t.TempDir(), not +// /tmp, so a test run never depends on or pollutes shared system state) via +// a REAL SSH exec channel, and proves the old flattening construction +// mangles a multi-word tmux argument while the new serializer preserves it. +func TestTmuxE2E_QuotingFixOverRealSSH(t *testing.T) { + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + t.Skip("tmux not available") + } + + sockPath := filepath.Join(t.TempDir(), "tmux.sock") + sessionName := "cc-pp-gatefix" + + newSession := exec.Command(tmuxPath, "-S", sockPath, "new-session", "-d", "-s", sessionName, "-x", "80", "-y", "24") + if out, err := newSession.CombinedOutput(); err != nil { + t.Fatalf("failed to start detached tmux session: %v, output: %s", err, out) + } + defer exec.Command(tmuxPath, "-S", sockPath, "kill-server").Run() + + capture := func() string { + out, err := exec.Command(tmuxPath, "-S", sockPath, "capture-pane", "-t", sessionName, "-p").Output() + if err != nil { + t.Fatalf("capture-pane failed: %v", err) + } + return string(out) + } + + // session.Run only waits for the SSH exec (tmux send-keys itself) to + // return, not for the shell INSIDE the tmux pane to process the injected + // keystrokes - that gap is what needs polling, not a fixed sleep, since + // its duration isn't bounded by anything the SSH call can observe. + waitForPane := func(deadline time.Duration, ready func(string) bool) string { + end := time.Now().Add(deadline) + var last string + for { + last = capture() + if ready(last) { + return last + } + if time.Now().After(end) { + t.Fatalf("timed out waiting for tmux pane state: %q", last) + } + time.Sleep(20 * time.Millisecond) + } + } + + // A settle sentinel, not a marker/"not found" text match, is the + // deterministic completion fence: the pane's shell processes lines in + // order, so once the sentinel echoes, whatever the prior command was + // going to print (clean output or a "command not found" error) has + // already happened. Sent directly via exec.Command (structured argv, + // bypassing SSH entirely) since the sentinel's own correctness isn't + // what's under test here. + settleCounter := 0 + waitForSettled := func(deadline time.Duration) string { + settleCounter++ + sentinel := fmt.Sprintf("settled-%d", settleCounter) + sendSentinel := exec.Command(tmuxPath, "-S", sockPath, "send-keys", "-t", sessionName, "echo "+sentinel, "Enter") + if err := sendSentinel.Run(); err != nil { + t.Fatalf("failed to send settle sentinel: %v", err) + } + // Match the sentinel as its own output line specifically - it also + // appears as a substring of the unexecuted, merely-echoed input line + // ("echo settled-N"), which would otherwise satisfy a plain Contains + // before the command has actually run. + return waitForPane(deadline, func(s string) bool { + return strings.Contains(s, "\n"+sentinel+"\n") + }) + } + + addr, closeFn := startTestSSHServer(t) + defer closeFn() + client := dialTestSSH(t, addr) + defer client.Close() + + runOverSSH := func(cmdCombined string) { + session, err := client.NewSession() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + defer session.Close() + if err := session.Run(cmdCombined); err != nil { + t.Fatalf("session.Run(%q) failed: %v", cmdCombined, err) + } + } + + // This is the exact multi-word-argument shape that broke over SSH: the + // literal keys to type ("echo tmux-e2e-marker-old") must survive as ONE + // send-keys argument, not get split into separate positional args. + marker := "tmux-e2e-marker" + argv := []string{tmuxPath, "-S", sockPath, "send-keys", "-t", sessionName, "echo " + marker + "-old", "Enter"} + + oldCmdCombined := strings.Join(argv, " ") // the original unquoted flatten + runOverSSH(oldCmdCombined) + oldPane := waitForSettled(2 * time.Second) + if strings.Contains(oldPane, marker+"-old\n") && !strings.Contains(oldPane, "not found") { + t.Fatalf("expected the old unquoted construction to mangle the tmux send-keys argument, but the pane shows a clean echo: %q", oldPane) + } + + // Clear the pane for a clean before/after comparison. + exec.Command(tmuxPath, "-S", sockPath, "send-keys", "-t", sessionName, "clear", "Enter").Run() + waitForSettled(1 * time.Second) + + argv[6] = "echo " + marker + "-new" + newCmdCombined := shellutil.SerializeCommandForShell(shellutil.ShellType_unknown, argv) + runOverSSH(newCmdCombined) + newPane := waitForSettled(2 * time.Second) + if !strings.Contains(newPane, marker+"-new") { + t.Fatalf("expected the new construction's send-keys to echo the marker cleanly, got pane content: %q", newPane) + } + if strings.Contains(newPane, "not found") { + t.Fatalf("new construction produced a shell error in the tmux pane: %q", newPane) + } +} diff --git a/pkg/util/shellutil/cmdserialize_test.go b/pkg/util/shellutil/cmdserialize_test.go new file mode 100644 index 0000000000..f1713e0f01 --- /dev/null +++ b/pkg/util/shellutil/cmdserialize_test.go @@ -0,0 +1,211 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +package shellutil + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestSerializeCommandForShell_Basic(t *testing.T) { + tests := []struct { + name string + shellType string + argv []string + want string + }{ + { + name: "empty argv", + shellType: ShellType_bash, + argv: nil, + want: "", + }, + { + name: "simple safe argv", + shellType: ShellType_bash, + argv: []string{"/bin/bash", "-c", "true"}, + want: `/bin/bash -c true`, + }, + { + name: "argument with spaces", + shellType: ShellType_bash, + argv: []string{"/bin/bash", "-c", "tmux attach -t cc-pp-gatefix"}, + want: `/bin/bash -c "tmux attach -t cc-pp-gatefix"`, + }, + { + name: "argument with double quotes and dollar", + shellType: ShellType_bash, + argv: []string{"echo", `it's "quoted" $HOME`}, + want: "echo \"it's \\\"quoted\\\" \\$HOME\"", + }, + { + name: "fish outer shell", + shellType: ShellType_fish, + argv: []string{"echo", "a b"}, + want: `echo "a b"`, + }, + { + name: "pwsh outer shell gets call operator", + shellType: ShellType_pwsh, + argv: []string{"C:\\wave pwsh.ps1", "-File", "a b"}, + want: "& \"C:\\wave pwsh.ps1\" \"-File\" \"a b\"", + }, + { + name: "unknown outer shell type falls back to POSIX quoting", + shellType: ShellType_unknown, + argv: []string{"sh", "-c", "a b"}, + want: `sh -c "a b"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SerializeCommandForShell(tt.shellType, tt.argv) + if got != tt.want { + t.Errorf("SerializeCommandForShell(%q, %v) = %q, want %q", tt.shellType, tt.argv, got, tt.want) + } + }) + } +} + +// HardQuotePowerShell must emit exactly one representation of a newline byte +// (the `n escape), not the escape followed by the original byte - the loop's +// unconditional trailing append previously doubled it, turning one newline +// in the input into two in the quoted output. +func TestHardQuotePowerShell_NewlineNotDoubled(t *testing.T) { + got := HardQuotePowerShell("line one\nline two") + want := "\"line one`nline two\"" + if got != want { + t.Errorf("HardQuotePowerShell(%q) = %q, want %q", "line one\nline two", got, want) + } + if strings.Contains(got, "\n") { + t.Errorf("HardQuotePowerShell(%q) = %q, still contains a raw newline byte", "line one\nline two", got) + } +} + +// This is the exact bug class SerializeCommandForShell exists to close: the +// old code built cmdCombined via `shellPath + " " + strings.Join(shellOpts, " ")` +// with no quoting at all, so `shellOpts = ["-c", "tmux attach -t foo"]` became +// `bash -c tmux attach -t foo` — "-c" only captured "tmux" as bash's command +// string, and "attach -t foo" leaked in as extra positional args ($1, $2...). +func TestSerializeCommandForShell_OldConstructionWasBroken(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + shellPath := "/bin/bash" + // cmdStr as it would arrive from a caller like `wsh run -- echo marker-a marker-b`: + // one string that must reach bash's "-c" as a SINGLE argument. + shellOpts := []string{"-c", "echo marker-a marker-b"} + want := "marker-a marker-b" + + // Old construction: unquoted flatten. "-c" ends up bound to just "echo", + // and "marker-a marker-b" leak in as bash's $1/$2 (unreferenced by the + // script), so the echo prints nothing instead of the expected words. + oldCmdCombined := shellPath + " " + strings.Join(shellOpts, " ") + out, err := exec.Command("sh", "-c", oldCmdCombined).Output() + if err != nil { + t.Fatalf("old construction errored unexpectedly: %v", err) + } + got := strings.TrimSpace(string(out)) + if got == want { + t.Fatalf("expected the old unquoted construction to lose argv boundaries, but it round-tripped correctly: %q", got) + } + + // New construction: SerializeCommandForShell hard-quotes the cmdStr as a + // single argv element, so bash's "-c" receives it whole. + newCmdCombined := SerializeCommandForShell(ShellType_unknown, append([]string{shellPath}, shellOpts...)) + out, err = exec.Command("sh", "-c", newCmdCombined).Output() + if err != nil { + t.Fatalf("new construction failed: %v", err) + } + got = strings.TrimSpace(string(out)) + if got != want { + t.Fatalf("new construction did not preserve the cmdStr as a single -c argument: got %q, want %q", got, want) + } +} + +// TestSerializeCommandForShell_FuzzAgainstRealShell is the fuzz/property test: +// for arbitrary argv elements (subject to the constraints os/exec already +// imposes — no NUL bytes), serializing with SerializeCommandForShell for a +// POSIX outer shell and running the result through a real `sh -c` must +// reproduce the exact original argv, byte for byte. This is what proves the +// serializer is round-trip-safe against a REAL shell's parser, not just our +// own assumptions about POSIX quoting rules. +func TestSerializeCommandForShell_FuzzAgainstRealShell(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + seeds := [][]string{ + {"a b"}, + {`c"d`}, + {"e$f"}, + {"g`h`"}, + {`i\j`}, + {""}, + {"~/k"}, + {"-l"}, + {"tab\ttab"}, + {"new\nline"}, + {"multi", "arg", "case", "with spaces", `"quoted"`, "$VAR", "`cmd`", `\`, "~"}, + {"unicode: héllo wörld 日本語"}, + } + for _, args := range seeds { + t.Run(strings.Join(args, "|"), func(t *testing.T) { + assertRoundTrip(t, args) + }) + } +} + +func FuzzSerializeCommandForShell(f *testing.F) { + if _, err := exec.LookPath("sh"); err != nil { + f.Skip("sh not available") + } + f.Add("simple") + f.Add("has space") + f.Add(`has "double" quotes`) + f.Add("has $dollar and `backtick`") + f.Add("has \\backslash\\") + f.Add("") + f.Add("~tilde") + f.Add("-dash-leading") + f.Add("multi\nline\ttabbed") + f.Fuzz(func(t *testing.T, arg string) { + if strings.ContainsRune(arg, 0) { + t.Skip("NUL bytes cannot appear in argv elements") + } + if strings.ContainsRune(arg, '\x1e') { + t.Skip("test harness uses \\x1e as its own output delimiter, not a real serializer constraint") + } + assertRoundTrip(t, []string{arg}) + }) +} + +// assertRoundTrip serializes ["printf", "%s\x1e", args...] for a POSIX outer +// shell, executes it via a real `sh -c`, and asserts the printed, \x1e-split +// output exactly matches args. +func assertRoundTrip(t *testing.T, args []string) { + t.Helper() + argv := append([]string{"printf", "%s\x1e"}, args...) + cmdLine := SerializeCommandForShell(ShellType_unknown, argv) + out, err := exec.Command("sh", "-c", cmdLine).Output() + if err != nil { + t.Fatalf("sh -c %q failed: %v", cmdLine, err) + } + trimmed := bytes.TrimSuffix(out, []byte("\x1e")) + var got []string + if len(trimmed) > 0 || len(args) > 0 { + got = strings.Split(string(trimmed), "\x1e") + } + if len(args) == 0 { + return + } + if len(got) != len(args) { + t.Fatalf("cmdLine=%q: got %d args %q, want %d args %q", cmdLine, len(got), got, len(args), args) + } + for i := range args { + if got[i] != args[i] { + t.Fatalf("cmdLine=%q: arg[%d] = %q, want %q", cmdLine, i, got[i], args[i]) + } + } +} diff --git a/pkg/util/shellutil/shellquote.go b/pkg/util/shellutil/shellquote.go index 504b52cd04..d26cfeab97 100644 --- a/pkg/util/shellutil/shellquote.go +++ b/pkg/util/shellutil/shellquote.go @@ -6,6 +6,7 @@ package shellutil import ( "log" "regexp" + "strings" ) const ( @@ -99,7 +100,11 @@ func HardQuotePowerShell(s string) string { case '"', '`', '$': buf = append(buf, '`') case '\n': - buf = append(buf, '`', 'n') // PowerShell uses `n for newline + // `n is the full replacement for the newline byte, not an + // escape prefix for it - unlike the cases above, don't also + // emit the original byte or the newline is doubled. + buf = append(buf, '`', 'n') + continue } buf = append(buf, c) } @@ -150,6 +155,49 @@ func SoftQuote(s string) string { return string(buf) } +// QuoteForShellType hard-quotes a single argv element for the given outer +// shell's syntax. This is the single place that knows which quoting dialect +// a shell type requires; callers must never hand-embed quote characters +// themselves (see SerializeCommandForShell). +func QuoteForShellType(shellType string, s string) string { + switch shellType { + case ShellType_fish: + return HardQuoteFish(s) + case ShellType_pwsh: + return HardQuotePowerShell(s) + default: + // bash, zsh, unknown, and any POSIX-compatible outer shell (e.g. the + // "sh -c" wrapper used for WSL) all use POSIX double-quote rules. + return HardQuote(s) + } +} + +// SerializeCommandForShell serializes an argv slice (argv[0] is the +// executable, the rest are its arguments) into a single command-line string +// that is safe to hand to the given outer shell type as a raw command string +// (an SSH exec request payload, or a "-c" argument). Every element is +// hard-quoted independently, so no element can be word-split, glob-expanded, +// or reinterpreted as a flag by the outer shell — this is the one place argv +// boundaries get flattened into shell syntax, and it must be the only one. +// +// PowerShell additionally requires the call operator "&" before a quoted +// executable path, or the quoted string is treated as a string literal +// instead of being invoked — that dialect quirk is centralized here so +// callers never need their own compensating "& " hack. +func SerializeCommandForShell(shellType string, argv []string) string { + if len(argv) == 0 { + return "" + } + quoted := make([]string, 0, len(argv)+1) + if shellType == ShellType_pwsh { + quoted = append(quoted, "&") + } + for _, a := range argv { + quoted = append(quoted, QuoteForShellType(shellType, a)) + } + return strings.Join(quoted, " ") +} + func checkQuoteSize(s string) bool { if len(s) > MaxQuoteSize { log.Printf("string too long to quote: %s", s) diff --git a/pkg/util/shellutil/tokenswap.go b/pkg/util/shellutil/tokenswap.go index 302bfa59b8..acfaeaeda0 100644 --- a/pkg/util/shellutil/tokenswap.go +++ b/pkg/util/shellutil/tokenswap.go @@ -7,6 +7,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "sort" + "strings" "sync" "time" @@ -96,42 +98,96 @@ func GetAndRemoveTokenSwapEntry(token string) *TokenSwapEntry { return nil } +// sortedEnvKeys returns env's keys in a deterministic (sorted) order so that +// encoding the same env map always produces byte-identical output — required +// both for reproducible tests and so logs/diffs of the encoded script are +// stable across runs. +func sortedEnvKeys(env map[string]string) []string { + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func encodeEnvVarsForBash(env map[string]string) (string, error) { var encoded string - for k, v := range env { + for _, k := range sortedEnvKeys(env) { // validate key if !IsValidEnvVarName(k) { return "", fmt.Errorf("invalid env var name: %q", k) } - encoded += fmt.Sprintf("export %s=%s\n", k, HardQuote(v)) + encoded += fmt.Sprintf("export %s=%s\n", k, HardQuote(env[k])) } return encoded, nil } func encodeEnvVarsForFish(env map[string]string) (string, error) { var encoded string - for k, v := range env { + for _, k := range sortedEnvKeys(env) { // validate key if !IsValidEnvVarName(k) { return "", fmt.Errorf("invalid env var name: %q", k) } - encoded += fmt.Sprintf("set -x %s %s\n", k, HardQuoteFish(v)) + encoded += fmt.Sprintf("set -x %s %s\n", k, HardQuoteFish(env[k])) } return encoded, nil } func encodeEnvVarsForPowerShell(env map[string]string) (string, error) { var encoded string - for k, v := range env { + for _, k := range sortedEnvKeys(env) { // validate key if !IsValidEnvVarName(k) { return "", fmt.Errorf("invalid env var name: %q", k) } - encoded += fmt.Sprintf("$env:%s = %s\n", k, HardQuotePowerShell(v)) + encoded += fmt.Sprintf("$env:%s = %s\n", k, HardQuotePowerShell(env[k])) } return encoded, nil } +// PrefixEnvAssignmentsForShell returns cmd prefixed with statements (in the +// given outer shell's own syntax) that set env, in deterministic sorted-key +// order. POSIX shells support the inline "VAR=val cmd" prefix form; fish and +// PowerShell do not support prefixing an arbitrary command this way, so their +// assignments are emitted as separate statements before cmd. Every value is +// hard-quoted for the target dialect — this is the single place a +// swap-token/JWT/ZDOTDIR value is spliced into a command line, so it is the +// only place that can leak an unquoted value into shell reparsing. +func PrefixEnvAssignmentsForShell(shellType string, env map[string]string, cmd string) string { + if len(env) == 0 { + return cmd + } + keys := sortedEnvKeys(env) + switch shellType { + case ShellType_fish: + var sb strings.Builder + for _, k := range keys { + fmt.Fprintf(&sb, "set -x %s %s; ", k, HardQuoteFish(env[k])) + } + sb.WriteString(cmd) + return sb.String() + case ShellType_pwsh: + var sb strings.Builder + for _, k := range keys { + fmt.Fprintf(&sb, "$env:%s = %s; ", k, HardQuotePowerShell(env[k])) + } + sb.WriteString(cmd) + return sb.String() + default: + var sb strings.Builder + for _, k := range keys { + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(HardQuote(env[k])) + sb.WriteByte(' ') + } + sb.WriteString(cmd) + return sb.String() + } +} + func EncodeEnvVarsForShell(shellType string, env map[string]string) (string, error) { switch shellType { case ShellType_bash, ShellType_zsh: