1package sshstdio
2
3import (
4 "bytes"
5 "context"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/vev/internal/protocol/wire"
14 "github.com/stretchr/testify/require"
15)
16
17// TestBuildCommandForMuxIsStdioOnlyAndTrustPreserving proves the documented mux
18// argv requests no PTY while leaving host-key, authentication, and proxy policy
19// entirely to the target's effective OpenSSH configuration.
20func TestBuildCommandForMuxIsStdioOnlyAndTrustPreserving(t *testing.T) {
21 spec := BuildCommandForMux("user@host; touch /tmp/pwn", "vev", "_mux", "--flag")
22 require.Equal(t, "ssh", spec.Path)
23 require.Equal(t, []string{"-T", "--", "user@host; touch /tmp/pwn", "'vev' '_mux' '--flag'"}, spec.Args)
24
25 flat := strings.Join(spec.Args, " ")
26 require.Contains(t, flat, "-T")
27 for _, forbidden := range []string{"StrictHostKeyChecking", "UserKnownHostsFile", "BatchMode", "ProxyCommand"} {
28 require.NotContains(t, flat, forbidden)
29 }
30}
31
32func TestDialMuxContextRejectsEmptyCommandSpec(t *testing.T) {
33 transport, err := DialMuxContext(context.Background(), CommandSpec{}, nil)
34 require.ErrorIs(t, err, ErrMuxConfig)
35 require.Nil(t, transport)
36}
37
38// TestDialMuxContextCanceledBeforeStart proves one canceled setup context is
39// checked before the subprocess starts: nothing is executed and the cancellation
40// remains recognizable.
41func TestDialMuxContextCanceledBeforeStart(t *testing.T) {
42 marker := filepath.Join(t.TempDir(), "started")
43 ctx, cancel := context.WithCancel(context.Background())
44 cancel()
45 transport, err := DialMuxContext(ctx, CommandSpec{Path: "sh", Args: []string{"-c", "touch " + marker + "; sleep 30"}}, nil)
46 require.ErrorIs(t, err, context.Canceled)
47 require.Nil(t, transport)
48 _, statErr := os.Stat(marker)
49 require.ErrorIs(t, statErr, os.ErrNotExist)
50}
51
52// TestDialMuxContextBoundedSanitizedStderr proves a non-clean mux child never
53// leaks raw remote stderr into the public error: the captured text is bounded
54// and sanitized for the logger, and the returned error carries only the typed
55// outcome.
56func TestDialMuxContextBoundedSanitizedStderr(t *testing.T) {
57 const marker = "remote-secret-value"
58 // A terminal-colored diagnostic, and a long tail the ceiling must drop.
59 script := "printf '\\033[31m" + marker + "\\033[0m' >&2; printf 'A%.0s' $(seq 1 20000) >&2; exit 3"
60
61 var logBuf bytes.Buffer
62 logger := slog.New(slog.NewJSONHandler(&logBuf, nil))
63 transport, err := DialMuxContext(context.Background(), CommandSpec{Path: "sh", Args: []string{"-c", script}}, logger)
64 require.NoError(t, err)
65 t.Cleanup(func() { _ = transport.Close() })
66
67 _, recvErr := transport.RecvBounded(wire.PreambleLimit)
68 require.ErrorIs(t, recvErr, ErrMuxSSHExit)
69 require.NotContains(t, recvErr.Error(), marker)
70 require.NotContains(t, recvErr.Error(), "\x1b")
71 require.Less(t, len(recvErr.Error()), muxDiagnosticLimit)
72
73 logged := logBuf.String()
74 require.Contains(t, logged, "ssh exited non-cleanly")
75 require.NotContains(t, logged, `\u001b`)
76 require.Less(t, len(logged), muxStderrLimit+2048, "log must stay bounded")
77}
78
79// TestCappedDiagnosticBoundsAndSanitizes proves the diagnostic capture both
80// caps its retained bytes and strips terminal escapes and control bytes.
81func TestCappedDiagnosticBoundsAndSanitizes(t *testing.T) {
82 sink := newCappedDiagnostic(16)
83 input := []byte("\x1b[31mok\x1b[0m\x00" + strings.Repeat("z", 100))
84 written, err := sink.Write(input)
85 require.NoError(t, err)
86 require.Equal(t, len(input), written)
87 require.True(t, sink.overflow)
88 require.LessOrEqual(t, sink.buf.Len(), 16)
89 require.Equal(t, "okz", sink.String()[:3])
90 require.NotContains(t, sink.String(), "\x1b")
91 require.NotContains(t, sink.String(), "\x00")
92 require.LessOrEqual(t, len(sink.String()), muxDiagnosticLimit)
93}
94
95// TestDialMuxContextCloseTerminatesProcess proves Close owns the subprocess
96// lifetime: it stops a child that ignores its stdin and returns without
97// abandoning the wait.
98func TestDialMuxContextCloseTerminatesProcess(t *testing.T) {
99 transport, err := DialMuxContext(context.Background(), CommandSpec{Path: "sh", Args: []string{"-c", "sleep 30"}}, nil)
100 require.NoError(t, err)
101
102 started := time.Now()
103 closeErr := transport.Close()
104 require.Error(t, closeErr, "a killed child is a non-clean exit")
105 require.Less(t, time.Since(started), sshCloseTimeout+2*time.Second)
106
107 // A second Close is idempotent and joins the same reaped process.
108 require.Error(t, transport.Close())
109 <-time.After(50 * time.Millisecond)
110}