pico
created pr with
112.1
added 112.2
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 -3checkout any patchset in a patch request:
ssh pr.pico.sh print 112.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 112
Patchset
112.2
chore: added test for pssh cmd parsing
Eric Bower
2026-02-24T16:35:41ZSemantic diff summary
7 added,
1 modified,
0 signature changed,
0 removed
across 1 analyzed file
+177
-0
pkg/pssh/server_test.go
#
| ... | ... | @@ -2,13 +2,19 @@ package pssh_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 5 | 6 | "errors" | |
| 7 | + | "io" | |
| 6 | 8 | "log/slog" | |
| 7 | 9 | "net" | |
| 10 | + | "slices" | |
| 11 | + | "strings" | |
| 8 | 12 | "testing" | |
| 9 | 13 | "time" | |
| 10 | 14 | ||
| 11 | 15 | "github.com/picosh/pico/pkg/pssh" | |
| 16 | + | "github.com/picosh/pico/pkg/shared" | |
| 17 | + | "golang.org/x/crypto/ed25519" | |
| 12 | 18 | "golang.org/x/crypto/ssh" | |
| 13 | 19 | ) | |
| 14 | 20 |
| ... | ... | @@ -275,3 +281,174 @@ func TestSSHServerConnHandle(t *testing.T) { | |
| 275 | 281 | t.Error("Handle did not return after context canceled") | |
| 276 | 282 | } | |
| 277 | 283 | } | |
| 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 | + | } |