Commit 740c00a

Eric Bower  ·  2026-02-26 09:04:53 -0500 EST
parent da9a246
fix(pssh): normalize line endings for pty enabled ssh output
2 files changed,  +228, -21
+15, -5
......@@ -197,12 +197,22 @@ func (s *SSHServerConnSession) Write(p []byte) (n int, err error) {
197197 return s.Channel.Write(p)
198198 }
199199
200- // When PTY is active, normalize line endings like a real terminal would.
201- // Replace \n with \r\n, but avoid double \r\n.
202- normalized := bytes.ReplaceAll(p, []byte{'\n'}, []byte{'\r', '\n'})
203- normalized = bytes.ReplaceAll(normalized, []byte{'\r', '\r', '\n'}, []byte{'\r', '\n'})
200+ // When PTY is active, ensure every \n is preceded by \r.
201+ // This ensures the cursor returns to column 0 before the newline.
202+ var buf bytes.Buffer
203+ for i := 0; i < len(p); i++ {
204+ if p[i] == '\n' {
205+ // Check if preceded by \r
206+ if i == 0 || p[i-1] != '\r' {
207+ buf.WriteByte('\r')
208+ }
209+ buf.WriteByte('\n')
210+ } else {
211+ buf.WriteByte(p[i])
212+ }
213+ }
204214
205- // Write the normalized data
215+ normalized := buf.Bytes()
206216 written, err := s.Channel.Write(normalized)
207217
208218 // Return the count based on original data length, not normalized
+213, -16
......@@ -1,16 +1,19 @@
11 package pssh_test
22
33 import (
4+ "bytes"
45 "context"
56 "crypto/rand"
67 "errors"
78 "io"
89 "log/slog"
910 "net"
11+ "reflect"
1012 "slices"
1113 "strings"
1214 "testing"
1315 "time"
16+ "unsafe"
1417
1518 "github.com/picosh/pico/pkg/pssh"
1619 "github.com/picosh/pico/pkg/shared"
......@@ -18,6 +21,198 @@ import (
1821 "golang.org/x/crypto/ssh"
1922 )
2023
24+// MockChannel implements ssh.Channel for testing PTY line-ending normalization.
25+type MockChannel struct {
26+ data []byte
27+}
28+
29+func (m *MockChannel) Read(data []byte) (n int, err error) {
30+ return 0, io.EOF
31+}
32+
33+func (m *MockChannel) Write(data []byte) (n int, err error) {
34+ m.data = append(m.data, data...)
35+ return len(data), nil
36+}
37+
38+func (m *MockChannel) Close() error {
39+ return nil
40+}
41+
42+func (m *MockChannel) CloseWrite() error {
43+ return nil
44+}
45+
46+func (m *MockChannel) SendRequest(name string, wantReply bool, data []byte) (bool, error) {
47+ return false, nil
48+}
49+
50+func (m *MockChannel) Stderr() io.ReadWriter {
51+ return &bytes.Buffer{}
52+}
53+
54+func (m *MockChannel) Data() []byte {
55+ return m.data
56+}
57+
58+// setPtyField sets the private pty field on a session (test only).
59+func setPtyField(session *pssh.SSHServerConnSession, pty *pssh.Pty) {
60+ field := reflect.ValueOf(session).Elem().FieldByName("pty")
61+ reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(pty))
62+}
63+
64+// TestSSHServerConnSessionWritePtyLineEnding verifies that line-ending normalization works correctly.
65+func TestSSHServerConnSessionWritePtyLineEnding(t *testing.T) {
66+ ctx := context.Background()
67+ logger := slog.Default()
68+ server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{})
69+
70+ // Create a mock SSH connection
71+ sshConn := &ssh.ServerConn{}
72+ serverConn := pssh.NewSSHServerConn(ctx, logger, sshConn, server)
73+
74+ // Create session with mock channel
75+ mockChannel := &MockChannel{}
76+
77+ createSession := func() *pssh.SSHServerConnSession {
78+ return &pssh.SSHServerConnSession{
79+ Channel: mockChannel,
80+ SSHServerConn: serverConn,
81+ Ctx: ctx,
82+ }
83+ }
84+
85+ t.Run("no PTY - write as-is", func(t *testing.T) {
86+ mockChannel.data = nil
87+ session := createSession()
88+ // No PTY is allocated, so behavior should write as-is
89+
90+ // Write text with just \n (no \r)
91+ input := []byte("line1\nline2\nline3")
92+ n, err := session.Write(input)
93+
94+ if err != nil {
95+ t.Errorf("unexpected error: %v", err)
96+ }
97+ if n != len(input) {
98+ t.Errorf("expected %d bytes written, got %d", len(input), n)
99+ }
100+ if !slices.Equal(mockChannel.data, input) {
101+ t.Errorf("expected %q, got %q", string(input), string(mockChannel.data))
102+ }
103+ })
104+
105+ t.Run("with PTY - normalize bare newlines to CRLF", func(t *testing.T) {
106+ mockChannel.data = nil
107+ session := createSession()
108+ // Set PTY on the session
109+ pty := &pssh.Pty{Term: "xterm", Window: pssh.Window{Width: 80, Height: 24}}
110+ setPtyField(session, pty)
111+
112+ // Write text with just \n (no \r)
113+ input := []byte("line1\nline2\nline3")
114+ expected := []byte("line1\r\nline2\r\nline3")
115+
116+ n, err := session.Write(input)
117+
118+ if err != nil {
119+ t.Errorf("unexpected error: %v", err)
120+ }
121+ // Should return original length
122+ if n != len(input) {
123+ t.Errorf("expected %d bytes written, got %d", len(input), n)
124+ }
125+ // Should write normalized data
126+ if !slices.Equal(mockChannel.data, expected) {
127+ t.Errorf("expected %q, got %q", string(expected), string(mockChannel.data))
128+ }
129+ })
130+
131+ t.Run("with PTY - preserve existing CRLF", func(t *testing.T) {
132+ mockChannel.data = nil
133+ session := createSession()
134+ pty := &pssh.Pty{Term: "xterm", Window: pssh.Window{Width: 80, Height: 24}}
135+ setPtyField(session, pty)
136+
137+ // Write text that already has proper \r\n
138+ input := []byte("line1\r\nline2\r\nline3")
139+ expected := []byte("line1\r\nline2\r\nline3") // Should not duplicate
140+
141+ n, err := session.Write(input)
142+
143+ if err != nil {
144+ t.Errorf("unexpected error: %v", err)
145+ }
146+ if n != len(input) {
147+ t.Errorf("expected %d bytes written, got %d", len(input), n)
148+ }
149+ if !slices.Equal(mockChannel.data, expected) {
150+ t.Errorf("expected %q, got %q", string(expected), string(mockChannel.data))
151+ }
152+ })
153+
154+ t.Run("with PTY - mixed newlines normalized correctly", func(t *testing.T) {
155+ mockChannel.data = nil
156+ session := createSession()
157+ pty := &pssh.Pty{Term: "xterm", Window: pssh.Window{Width: 80, Height: 24}}
158+ setPtyField(session, pty)
159+
160+ // Mix of \n and \r\n
161+ input := []byte("line1\nline2\r\nline3\nline4")
162+ expected := []byte("line1\r\nline2\r\nline3\r\nline4")
163+
164+ n, err := session.Write(input)
165+
166+ if err != nil {
167+ t.Errorf("unexpected error: %v", err)
168+ }
169+ if n != len(input) {
170+ t.Errorf("expected %d bytes written, got %d", len(input), n)
171+ }
172+ if !slices.Equal(mockChannel.data, expected) {
173+ t.Errorf("expected %q, got %q", string(expected), string(mockChannel.data))
174+ }
175+ })
176+
177+ t.Run("staircase bug regression - sequential writes maintain formatting", func(t *testing.T) {
178+ // This test simulates the staircase bug where multiple writes
179+ // without proper CRLF would cause progressive indentation
180+ mockChannel.data = nil
181+ session := createSession()
182+ pty := &pssh.Pty{Term: "xterm", Window: pssh.Window{Width: 80, Height: 24}}
183+ setPtyField(session, pty)
184+
185+ // Simulate help text being written in multiple chunks
186+ writes := []string{
187+ "NAME:\n",
188+ "\tssh - A tool\n",
189+ "\n",
190+ "USAGE:\n",
191+ "\tssh [options]\n",
192+ }
193+
194+ for _, w := range writes {
195+ _, err := session.Write([]byte(w))
196+ if err != nil {
197+ t.Errorf("unexpected error: %v", err)
198+ }
199+ }
200+
201+ // Check that every \n is preceded by \r to prevent staircase
202+ output := mockChannel.data
203+ for i := 0; i < len(output); i++ {
204+ if output[i] == '\n' {
205+ if i == 0 {
206+ t.Errorf("newline at position 0 not preceded by carriage return")
207+ } else if output[i-1] != '\r' {
208+ t.Errorf("newline at position %d not preceded by carriage return, got %q before it",
209+ i, string(output[i-1]))
210+ }
211+ }
212+ }
213+ })
214+}
215+
21216 func TestNewSSHServer(t *testing.T) {
22217 ctx := context.Background()
23218 logger := slog.Default()
......@@ -291,8 +486,9 @@ func TestSSHServerCommandParsing(t *testing.T) {
291486
292487 user := GenerateKey()
293488
489+ // Use dynamic port (0) to avoid port conflicts
294490 server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{
295- ListenAddr: "localhost:2222",
491+ ListenAddr: "127.0.0.1:0",
296492 Middleware: []pssh.SSHServerMiddleware{
297493 func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
298494 return func(sesh *pssh.SSHServerConnSession) error {
......@@ -321,11 +517,22 @@ func TestSSHServerCommandParsing(t *testing.T) {
321517 errChan <- err
322518 }()
323519
324- // Wait a bit for the server to start
325- time.Sleep(100 * time.Millisecond)
520+ // Wait for server to be ready and get the actual listening address
521+ var actualAddr string
522+ for i := 0; i < 50; i++ {
523+ if server.Listener != nil {
524+ actualAddr = server.Listener.Addr().String()
525+ break
526+ }
527+ time.Sleep(10 * time.Millisecond)
528+ }
529+
530+ if actualAddr == "" {
531+ t.Fatal("server listener not ready")
532+ }
326533
327534 // Send command to server
328- _, _ = user.Cmd(nil, "accept --comment 'here we go' 101")
535+ _, _ = user.CmdAddr(nil, actualAddr, "accept --comment 'here we go' 101")
329536
330537 time.Sleep(100 * time.Millisecond)
331538
......@@ -365,17 +572,7 @@ func (s UserSSH) Public() string {
365572 return string(ssh.MarshalAuthorizedKey(pubkey))
366573 }
367574
368-func (s UserSSH) MustCmd(patch []byte, cmd string) string {
369- res, err := s.Cmd(patch, cmd)
370- if err != nil {
371- panic(err)
372- }
373- return res
374-}
375-
376-func (s UserSSH) Cmd(patch []byte, cmd string) (string, error) {
377- host := "localhost:2222"
378-
575+func (s UserSSH) CmdAddr(patch []byte, addr string, cmd string) (string, error) {
379576 config := &ssh.ClientConfig{
380577 User: s.username,
381578 Auth: []ssh.AuthMethod{
......@@ -384,7 +581,7 @@ func (s UserSSH) Cmd(patch []byte, cmd string) (string, error) {
384581 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
385582 }
386583
387- client, err := ssh.Dial("tcp", host, config)
584+ client, err := ssh.Dial("tcp", addr, config)
388585 if err != nil {
389586 return "", err
390587 }