返回 DeepSeek-Reasonix
client_test.go
根目录 / internal / remote / client_test.go
1 package remote
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "reasonix/internal/remote/sshtest"
16 )
17
18 // managedOnlyPolicy points the host-key policy at an isolated managed file and
19 // no system files, with an accept-all prompt, so tests never touch ~/.ssh.
20 func managedOnlyPolicy(t *testing.T, accept bool) *HostKeyPolicy {
21 t.Helper()
22 return &HostKeyPolicy{
23 SystemKnownHosts: []string{filepath.Join(t.TempDir(), "none")},
24 ManagedPath: filepath.Join(t.TempDir(), "known_hosts"),
25 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
26 return accept, nil
27 },
28 }
29 }
30
31 func newTestClient(t *testing.T, srv *sshtest.Server, opts Options) *Client {
32 t.Helper()
33 host, err := ResolveHost(nil, "test@"+srv.Addr, nil)
34 if err != nil {
35 t.Fatal(err)
36 }
37 opts.Host = host
38 if opts.HostKeys == nil {
39 opts.HostKeys = managedOnlyPolicy(t, true)
40 }
41 c, err := New(opts)
42 if err != nil {
43 t.Fatal(err)
44 }
45 return c
46 }
47
48 // TestExecSendsBareShCommand pins the issue #8130 wire contract: the remote
49 // side receives exactly `sh` — never a quoted payload — so the user's login
50 // shell (tcsh/csh/zsh) has nothing to misinterpret — and the full script
51 // arrives intact via the session stdin.
52 func TestExecSendsBareShCommand(t *testing.T) {
53 // gotCmd is written by the sshtest handler goroutine and read by the test
54 // goroutine after Exec returns; the network round trip alone establishes no
55 // happens-before, so the pair must be synchronized for -race.
56 var (
57 mu sync.Mutex
58 gotCmd string
59 )
60 srv := sshtest.Start(t, sshtest.Options{
61 Password: "hunter2",
62 Exec: func(cmd string) (string, string, int) {
63 mu.Lock()
64 gotCmd = cmd
65 mu.Unlock()
66 return "ok", "", 0
67 },
68 })
69 c := newTestClient(t, srv, Options{Auth: AuthOptions{Password: func() (string, error) { return "hunter2", nil }}})
70 if err := c.Start(context.Background()); err != nil {
71 t.Fatalf("Start: %v", err)
72 }
73 defer c.Close()
74 payload := `echo 'x; echo PWNED; echo y'`
75 res, err := c.Exec(context.Background(), payload)
76 if err != nil {
77 t.Fatalf("Exec: %v", err)
78 }
79 mu.Lock()
80 cmd := gotCmd
81 mu.Unlock()
82 if cmd != "sh" {
83 t.Fatalf("remote command = %q, want exactly %q", cmd, "sh")
84 }
85 if strings.TrimSpace(string(res.Stdout)) != "ok" {
86 t.Fatalf("stdout = %q, want ok", res.Stdout)
87 }
88 // The script travels via stdin and must arrive byte-identical.
89 deadline := time.Now().Add(2 * time.Second)
90 for srv.LastStdin() != payload && time.Now().Before(deadline) {
91 time.Sleep(5 * time.Millisecond)
92 }
93 if got := srv.LastStdin(); got != payload {
94 t.Fatalf("session stdin = %q, want the full payload %q", got, payload)
95 }
96 }
97
98 // TestShViaStdinRoundTripsPayload verifies the stdin-fed script executes under
99 // /bin/sh with quoting intact — the same stdin path the remote `sh` uses.
100 func TestShViaStdinRoundTripsPayload(t *testing.T) {
101 if _, err := exec.LookPath("sh"); err != nil {
102 t.Skip("sh not on PATH")
103 }
104 payload := `echo 'x; echo PWNED; echo y'`
105 cmd := exec.Command("sh")
106 cmd.Stdin = strings.NewReader(payload)
107 out, err := cmd.Output()
108 if err != nil {
109 t.Fatalf("sh via stdin failed: %v", err)
110 }
111 if string(out) != "x; echo PWNED; echo y\n" {
112 t.Fatalf("stdin payload output = %q, want literal %q", out, "x; echo PWNED; echo y\n")
113 }
114 }
115
116 // TestExecSurvivesTcshLoginShell reproduces the sshd execution model on a host
117 // whose login shell is tcsh: the outer command is `sh` (trivially parsed) and
118 // the script arrives via stdin. Skipped when tcsh is unavailable (Linux CI).
119 func TestExecSurvivesTcshLoginShell(t *testing.T) {
120 tcsh, err := exec.LookPath("tcsh")
121 if err != nil {
122 t.Skip("tcsh not installed")
123 }
124 payload := `SX=; command -v setsid >/dev/null 2>&1 && SX=setsid; echo "probe-ok $SX"`
125 cmd := exec.Command(tcsh, "-fc", "sh")
126 cmd.Stdin = strings.NewReader(payload)
127 out, err := cmd.Output()
128 if err != nil {
129 t.Fatalf("tcsh -fc sh via stdin failed: %v", err)
130 }
131 if got := strings.TrimSpace(string(out)); !strings.HasPrefix(got, "probe-ok") {
132 t.Fatalf("output = %q, want prefix probe-ok", out)
133 }
134 // A payload with POSIX '\'' escaping (bootstrap quoted paths) must survive
135 // too — the escaping is resolved inside sh, never by tcsh.
136 quoted := `echo 'a'\''b'`
137 cmd2 := exec.Command(tcsh, "-fc", "sh")
138 cmd2.Stdin = strings.NewReader(quoted)
139 out2, err := cmd2.Output()
140 if err != nil {
141 t.Fatalf("quoted payload via tcsh sh failed: %v", err)
142 }
143 if string(out2) != "a'b\n" {
144 t.Fatalf("quoted output = %q, want a'b", out2)
145 }
146 }
147
148 func TestClientConnectPasswordAuth(t *testing.T) {
149 srv := sshtest.Start(t, sshtest.Options{Password: "hunter2"})
150 c := newTestClient(t, srv, Options{
151 Auth: AuthOptions{
152 DisableAgent: true,
153 Password: func() (string, error) { return "hunter2", nil },
154 },
155 })
156 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
157 defer cancel()
158 if err := c.Start(ctx); err != nil {
159 t.Fatalf("Start: %v", err)
160 }
161 defer c.Close()
162 if c.Status().Status != StatusConnected {
163 t.Fatalf("status = %v, want connected", c.Status().Status)
164 }
165 res, err := c.Exec(ctx, "echo hello")
166 if err != nil {
167 t.Fatalf("Exec: %v", err)
168 }
169 // The sshtest server echoes the received exec command; since issue #8130
170 // the remote side receives exactly `sh` — the stdin script path is covered
171 // by TestShViaStdinRoundTripsPayload and the tcsh test.
172 if strings.TrimSpace(string(res.Stdout)) != "sh" {
173 t.Fatalf("exec stdout = %q, want the bare `sh` command", res.Stdout)
174 }
175 }
176
177 func TestClientConnectPublicKeyAuth(t *testing.T) {
178 pemBytes, pub, err := sshtest.GenerateKeyPEM()
179 if err != nil {
180 t.Fatal(err)
181 }
182 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
183 keyPath := filepath.Join(t.TempDir(), "id_ed25519")
184 if err := writeFile0600(keyPath, pemBytes); err != nil {
185 t.Fatal(err)
186 }
187 c := newTestClient(t, srv, Options{})
188 c.opts.Host.IdentityFile = keyPath
189 c.opts.Auth.DisableAgent = true
190
191 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
192 defer cancel()
193 if err := c.Start(ctx); err != nil {
194 t.Fatalf("Start: %v", err)
195 }
196 defer c.Close()
197 if c.Status().Status != StatusConnected {
198 t.Fatalf("status = %v", c.Status().Status)
199 }
200 }
201
202 func TestIdentityFileNoneSuppressesDefaultKeys(t *testing.T) {
203 pemBytes, pub, err := sshtest.GenerateKeyPEM()
204 if err != nil {
205 t.Fatal(err)
206 }
207 home := t.TempDir()
208 t.Setenv("HOME", home)
209 t.Setenv("USERPROFILE", home)
210 sshDir := filepath.Join(home, ".ssh")
211 if err := os.MkdirAll(sshDir, 0o700); err != nil {
212 t.Fatal(err)
213 }
214 if err := writeFile0600(filepath.Join(sshDir, "id_ed25519"), pemBytes); err != nil {
215 t.Fatal(err)
216 }
217 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
218 c := newTestClient(t, srv, Options{Auth: AuthOptions{DisableAgent: true}})
219 c.opts.Host.IdentityFileNone = true
220 c.opts.Host.IdentitiesOnly = true
221
222 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
223 defer cancel()
224 if err := c.Start(ctx); err == nil {
225 defer c.Close()
226 t.Fatal("IdentityFile none unexpectedly offered a default private key")
227 }
228 }
229
230 func TestClientTriesMultipleIdentityFilesInOrder(t *testing.T) {
231 wrongPEM, _, err := sshtest.GenerateKeyPEM()
232 if err != nil {
233 t.Fatal(err)
234 }
235 correctPEM, correctPublic, err := sshtest.GenerateKeyPEM()
236 if err != nil {
237 t.Fatal(err)
238 }
239 // The server accepts the second configured identity, not the first.
240 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: correctPublic})
241 dir := t.TempDir()
242 wrongPath := filepath.Join(dir, "id_wrong")
243 correctPath := filepath.Join(dir, "id_correct")
244 if err := writeFile0600(wrongPath, wrongPEM); err != nil {
245 t.Fatal(err)
246 }
247 if err := writeFile0600(correctPath, correctPEM); err != nil {
248 t.Fatal(err)
249 }
250 c := newTestClient(t, srv, Options{})
251 c.opts.Host.IdentityFile = wrongPath
252 c.opts.Host.IdentityFiles = []string{wrongPath, correctPath}
253 c.opts.Auth.DisableAgent = true
254
255 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
256 defer cancel()
257 if err := c.Start(ctx); err != nil {
258 t.Fatalf("Start with second valid identity: %v", err)
259 }
260 defer c.Close()
261 if c.Status().Status != StatusConnected {
262 t.Fatalf("status = %v, want connected", c.Status().Status)
263 }
264 }
265
266 func TestClientFallsBackFromUnavailableAgentToIdentityFile(t *testing.T) {
267 pemBytes, authorized, err := sshtest.GenerateKeyPEM()
268 if err != nil {
269 t.Fatal(err)
270 }
271 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
272 keyPath := filepath.Join(t.TempDir(), "id_ed25519")
273 if err := writeFile0600(keyPath, pemBytes); err != nil {
274 t.Fatal(err)
275 }
276 t.Setenv("SSH_AUTH_SOCK", filepath.Join(t.TempDir(), "missing-agent.sock"))
277
278 c := newTestClient(t, srv, Options{})
279 c.opts.Host.IdentityFile = keyPath
280
281 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
282 defer cancel()
283 if err := c.Start(ctx); err != nil {
284 t.Fatalf("Start with unavailable agent and explicit identity: %v", err)
285 }
286 defer c.Close()
287 if c.Status().Status != StatusConnected {
288 t.Fatalf("status = %v, want connected", c.Status().Status)
289 }
290 }
291
292 func TestClientConnectEncryptedPublicKeyAuth(t *testing.T) {
293 pemBytes, pub, err := sshtest.GenerateEncryptedKeyPEM("correct horse battery staple")
294 if err != nil {
295 t.Fatal(err)
296 }
297 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
298 keyPath := filepath.Join(t.TempDir(), "id_ed25519")
299 if err := writeFile0600(keyPath, pemBytes); err != nil {
300 t.Fatal(err)
301 }
302 c := newTestClient(t, srv, Options{})
303 c.opts.Host.IdentityFile = keyPath
304 c.opts.Auth = AuthOptions{
305 DisableAgent: true,
306 Passphrase: func() (string, error) { return "correct horse battery staple", nil },
307 }
308
309 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
310 defer cancel()
311 if err := c.Start(ctx); err != nil {
312 t.Fatalf("Start: %v", err)
313 }
314 defer c.Close()
315 if c.Status().Status != StatusConnected {
316 t.Fatalf("status = %v, want connected", c.Status().Status)
317 }
318 }
319
320 func TestClientPromptsPerEncryptedIdentity(t *testing.T) {
321 wrongPEM, _, err := sshtest.GenerateEncryptedKeyPEM("first-key-passphrase")
322 if err != nil {
323 t.Fatal(err)
324 }
325 correctPEM, authorized, err := sshtest.GenerateEncryptedKeyPEM("second-key-passphrase")
326 if err != nil {
327 t.Fatal(err)
328 }
329 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
330 dir := t.TempDir()
331 wrongPath := filepath.Join(dir, "id_wrong_encrypted")
332 correctPath := filepath.Join(dir, "id_correct_encrypted")
333 if err := writeFile0600(wrongPath, wrongPEM); err != nil {
334 t.Fatal(err)
335 }
336 if err := writeFile0600(correctPath, correctPEM); err != nil {
337 t.Fatal(err)
338 }
339 prompts := map[string]int{}
340 c := newTestClient(t, srv, Options{})
341 c.opts.Host.IdentityFile = wrongPath
342 c.opts.Host.IdentityFiles = []string{wrongPath, correctPath}
343 c.opts.Auth = AuthOptions{
344 DisableAgent: true,
345 SecretPrompt: func(_ context.Context, kind SecretKind, _ string, identityFile string) (string, error) {
346 if kind != SecretPassphrase {
347 t.Fatalf("prompt kind = %v, want passphrase", kind)
348 }
349 prompts[identityFile]++
350 switch identityFile {
351 case wrongPath:
352 return "first-key-passphrase", nil
353 case correctPath:
354 return "second-key-passphrase", nil
355 default:
356 return "", fmt.Errorf("unexpected identity %q", identityFile)
357 }
358 },
359 }
360
361 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
362 defer cancel()
363 if err := c.Start(ctx); err != nil {
364 t.Fatalf("Start with separately encrypted identities: %v", err)
365 }
366 defer c.Close()
367 if prompts[wrongPath] != 1 || prompts[correctPath] != 1 {
368 t.Fatalf("passphrase prompts = %v, want one per identity", prompts)
369 }
370 }
371
372 func TestClientFallsBackFromStoredPassphraseToPerIdentityPrompt(t *testing.T) {
373 wrongPEM, _, err := sshtest.GenerateEncryptedKeyPEM("first-key-passphrase")
374 if err != nil {
375 t.Fatal(err)
376 }
377 correctPEM, authorized, err := sshtest.GenerateEncryptedKeyPEM("second-key-passphrase")
378 if err != nil {
379 t.Fatal(err)
380 }
381 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
382 dir := t.TempDir()
383 wrongPath := filepath.Join(dir, "id_wrong_encrypted")
384 correctPath := filepath.Join(dir, "id_correct_encrypted")
385 if err := writeFile0600(wrongPath, wrongPEM); err != nil {
386 t.Fatal(err)
387 }
388 if err := writeFile0600(correctPath, correctPEM); err != nil {
389 t.Fatal(err)
390 }
391 var prompted []string
392 c := newTestClient(t, srv, Options{})
393 c.opts.Host.IdentityFile = wrongPath
394 c.opts.Host.IdentityFiles = []string{wrongPath, correctPath}
395 c.opts.Auth = AuthOptions{
396 DisableAgent: true,
397 // The saved host-level value unlocks the second key only.
398 Passphrase: func() (string, error) { return "second-key-passphrase", nil },
399 SecretPrompt: func(_ context.Context, kind SecretKind, _ string, identityFile string) (string, error) {
400 if kind != SecretPassphrase || identityFile != wrongPath {
401 return "", fmt.Errorf("unexpected prompt kind=%v identity=%q", kind, identityFile)
402 }
403 prompted = append(prompted, identityFile)
404 return "first-key-passphrase", nil
405 },
406 }
407
408 // This handshake performs three passphrase KDFs (stored + prompted for the
409 // first identity, then stored for the second). Under full -race package
410 // parallelism on a constrained CI runner, ten seconds is too close to the CPU
411 // bound work even though the in-process SSH server remains responsive.
412 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
413 defer cancel()
414 if err := c.Start(ctx); err != nil {
415 t.Fatalf("Start with stored and per-identity passphrases: %v", err)
416 }
417 defer c.Close()
418 if len(prompted) != 1 || prompted[0] != wrongPath {
419 t.Fatalf("identity prompts = %v, want only %q", prompted, wrongPath)
420 }
421 }
422
423 func TestRejectedPublicKeyDoesNotReportMissingPasswordPrompt(t *testing.T) {
424 _, authorized, err := sshtest.GenerateKeyPEM()
425 if err != nil {
426 t.Fatal(err)
427 }
428 wrongPEM, _, err := sshtest.GenerateKeyPEM()
429 if err != nil {
430 t.Fatal(err)
431 }
432 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
433 keyPath := filepath.Join(t.TempDir(), "wrong_id_ed25519")
434 if err := writeFile0600(keyPath, wrongPEM); err != nil {
435 t.Fatal(err)
436 }
437 c := newTestClient(t, srv, Options{})
438 c.opts.Host.IdentityFile = keyPath
439 c.opts.Auth.DisableAgent = true
440
441 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
442 defer cancel()
443 err = c.Start(ctx)
444 if err == nil {
445 t.Fatal("expected authentication failure")
446 }
447 if !errors.Is(err, ErrAuthFailed) {
448 t.Fatalf("error = %v, want ErrAuthFailed", err)
449 }
450 if strings.Contains(err.Error(), "password required") || strings.Contains(err.Error(), "no prompt available") {
451 t.Fatalf("public-key rejection was masked by a password-prompt error: %v", err)
452 }
453 }
454
455 func TestClientAuthFailureStops(t *testing.T) {
456 srv := sshtest.Start(t, sshtest.Options{Password: "correct"})
457 c := newTestClient(t, srv, Options{
458 Auth: AuthOptions{
459 DisableAgent: true,
460 Password: func() (string, error) { return "wrong", nil },
461 },
462 })
463 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
464 defer cancel()
465 err := c.Start(ctx)
466 if err == nil {
467 t.Fatal("expected auth failure")
468 }
469 if c.Status().Status != StatusStopped {
470 t.Fatalf("status = %v, want stopped", c.Status().Status)
471 }
472 }
473
474 func TestClientHostKeyRejectedStops(t *testing.T) {
475 srv := sshtest.Start(t, sshtest.Options{Password: "x"})
476 c := newTestClient(t, srv, Options{
477 HostKeys: managedOnlyPolicy(t, false), // reject TOFU
478 Auth: AuthOptions{
479 DisableAgent: true,
480 Password: func() (string, error) { return "x", nil },
481 },
482 })
483 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
484 defer cancel()
485 err := c.Start(ctx)
486 if err == nil {
487 t.Fatal("expected host key rejection")
488 }
489 }
490
491 func TestClientHostKeyTOFUPersistsAndReconnectsSilently(t *testing.T) {
492 srv := sshtest.Start(t, sshtest.Options{Password: "x"})
493 managed := filepath.Join(t.TempDir(), "known_hosts")
494 prompted := 0
495 policy := &HostKeyPolicy{
496 SystemKnownHosts: []string{filepath.Join(t.TempDir(), "none")},
497 ManagedPath: managed,
498 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
499 prompted++
500 return true, nil
501 },
502 }
503 host, _ := ResolveHost(nil, "test@"+srv.Addr, nil)
504 mkClient := func() *Client {
505 c, err := New(Options{
506 Host: host,
507 HostKeys: policy,
508 Auth: AuthOptions{DisableAgent: true, Password: func() (string, error) { return "x", nil }},
509 })
510 if err != nil {
511 t.Fatal(err)
512 }
513 return c
514 }
515
516 ctx := context.Background()
517 c1 := mkClient()
518 if err := c1.Start(ctx); err != nil {
519 t.Fatalf("first connect: %v", err)
520 }
521 c1.Close()
522 if prompted != 1 {
523 t.Fatalf("expected exactly 1 prompt on first connect, got %d", prompted)
524 }
525
526 // Second connect should find the key in the managed file: no prompt.
527 c2 := mkClient()
528 if err := c2.Start(ctx); err != nil {
529 t.Fatalf("second connect: %v", err)
530 }
531 c2.Close()
532 if prompted != 1 {
533 t.Fatalf("second connect re-prompted (count=%d); TOFU key was not persisted", prompted)
534 }
535 }
536
537 func writeFile0600(path string, data []byte) error {
538 return os.WriteFile(path, data, 0o600)
539 }
540
540 lines GO