pico

created pr with 112.1 on 2026-02-24T22:19:31Z · by c8ef7d19
added 112.2 on 2026-02-25T01:29:54Z · by c8ef7d19
1: 92facd1 = 1: 92facd1 chore: added test for pssh cmd parsing
-: ------- > 2: 1acc4d5 fix: properly parse ssh command args with quotes
cmds
checkout latest patchset:
ssh pr.pico.sh print 112 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 112.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 112
+177 -0 pkg/pssh/server_test.go #
......@@ -2,13 +2,19 @@ package pssh_test
22
33 import (
44 "context"
5+ "crypto/rand"
56 "errors"
7+ "io"
68 "log/slog"
79 "net"
10+ "slices"
11+ "strings"
812 "testing"
913 "time"
1014
1115 "github.com/picosh/pico/pkg/pssh"
16+ "github.com/picosh/pico/pkg/shared"
17+ "golang.org/x/crypto/ed25519"
1218 "golang.org/x/crypto/ssh"
1319 )
1420
......@@ -275,3 +281,174 @@ func TestSSHServerConnHandle(t *testing.T) {
275281 t.Error("Handle did not return after context canceled")
276282 }
277283 }
284+
285+func TestSSHServerCommandParsing(t *testing.T) {
286+ ctx, cancel := context.WithCancel(context.Background())
287+ defer cancel()
288+
289+ logger := slog.Default()
290+ var capturedCommand []string
291+
292+ user := GenerateKey()
293+
294+ server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{
295+ ListenAddr: "localhost:2222",
296+ Middleware: []pssh.SSHServerMiddleware{
297+ func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
298+ return func(sesh *pssh.SSHServerConnSession) error {
299+ capturedCommand = sesh.Command()
300+ return next(sesh)
301+ }
302+ },
303+ },
304+ ServerConfig: &ssh.ServerConfig{
305+ NoClientAuth: true,
306+ NoClientAuthCallback: func(ssh.ConnMetadata) (*ssh.Permissions, error) {
307+ return &ssh.Permissions{
308+ Extensions: map[string]string{
309+ "pubkey": shared.KeyForKeyText(user.signer.PublicKey()),
310+ },
311+ }, nil
312+ },
313+ },
314+ })
315+ server.Config.AddHostKey(user.signer)
316+
317+ // Start server in a goroutine
318+ errChan := make(chan error, 1)
319+ go func() {
320+ err := server.ListenAndServe()
321+ errChan <- err
322+ }()
323+
324+ // Wait a bit for the server to start
325+ time.Sleep(100 * time.Millisecond)
326+
327+ // Send command to server
328+ user.MustCmd(nil, "accept --comment 'here we go' 101")
329+
330+ time.Sleep(1000 * time.Millisecond)
331+
332+ expectedCommand := []string{"accept", "--comment", "'here we go'", "101"}
333+ if !slices.Equal(expectedCommand, capturedCommand) {
334+ t.Error("command not exected", capturedCommand, len(capturedCommand), expectedCommand, len(expectedCommand))
335+ }
336+
337+ // Trigger cancellation to stop the server
338+ cancel()
339+
340+ // Wait for server to stop
341+ select {
342+ case err := <-errChan:
343+ if err != nil && !errors.Is(err, net.ErrClosed) {
344+ t.Errorf("unexpected error: %v", err)
345+ }
346+ case <-time.After(2 * time.Second):
347+ t.Error("server did not shut down in time")
348+ }
349+}
350+
351+type UserSSH struct {
352+ username string
353+ signer ssh.Signer
354+}
355+
356+func NewUserSSH(username string, signer ssh.Signer) *UserSSH {
357+ return &UserSSH{
358+ username: username,
359+ signer: signer,
360+ }
361+}
362+
363+func (s UserSSH) Public() string {
364+ pubkey := s.signer.PublicKey()
365+ return string(ssh.MarshalAuthorizedKey(pubkey))
366+}
367+
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+
379+ config := &ssh.ClientConfig{
380+ User: s.username,
381+ Auth: []ssh.AuthMethod{
382+ ssh.PublicKeys(s.signer),
383+ },
384+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
385+ }
386+
387+ client, err := ssh.Dial("tcp", host, config)
388+ if err != nil {
389+ return "", err
390+ }
391+ defer func() {
392+ _ = client.Close()
393+ }()
394+
395+ session, err := client.NewSession()
396+ if err != nil {
397+ return "", err
398+ }
399+ defer func() {
400+ _ = session.Close()
401+ }()
402+
403+ stdinPipe, err := session.StdinPipe()
404+ if err != nil {
405+ return "", err
406+ }
407+
408+ stdoutPipe, err := session.StdoutPipe()
409+ if err != nil {
410+ return "", err
411+ }
412+
413+ if err := session.Start(cmd); err != nil {
414+ return "", err
415+ }
416+
417+ if patch != nil {
418+ _, err = stdinPipe.Write(patch)
419+ if err != nil {
420+ return "", err
421+ }
422+ }
423+
424+ _ = stdinPipe.Close()
425+
426+ if err := session.Wait(); err != nil {
427+ return "", err
428+ }
429+
430+ buf := new(strings.Builder)
431+ _, err = io.Copy(buf, stdoutPipe)
432+ if err != nil {
433+ return "", err
434+ }
435+
436+ return buf.String(), nil
437+}
438+
439+func GenerateKey() UserSSH {
440+ _, userKey, err := ed25519.GenerateKey(rand.Reader)
441+ if err != nil {
442+ panic(err)
443+ }
444+
445+ userSigner, err := ssh.NewSignerFromKey(userKey)
446+ if err != nil {
447+ panic(err)
448+ }
449+
450+ return UserSSH{
451+ username: "user",
452+ signer: userSigner,
453+ }
454+}
Back to top