返回 DeepSeek-Reasonix
browser_broker_test.go
根目录 / desktop / browser_broker_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "sync"
16 "testing"
17
18 "golang.org/x/crypto/ssh"
19
20 "reasonix/internal/browser"
21 "reasonix/internal/remote/sftpfs"
22 "reasonix/internal/remote/sshtest"
23 )
24
25 // brokerFakeExecutor answers from fixed fields and records the session each
26 // call arrived with (the HTTP handler restores it into the context).
27 type brokerFakeExecutor struct {
28 mu sync.Mutex
29 tabs []browser.Tab
30 screenshot browser.Screenshot
31 downloads []browser.Download
32 sessions []string
33 uploadDirectory string
34 onAct func(browser.ActRequest)
35 }
36
37 func (e *brokerFakeExecutor) note(ctx context.Context) {
38 e.mu.Lock()
39 e.sessions = append(e.sessions, browser.SessionFromContext(ctx))
40 e.mu.Unlock()
41 }
42
43 func (e *brokerFakeExecutor) lastSession() string {
44 e.mu.Lock()
45 defer e.mu.Unlock()
46 if len(e.sessions) == 0 {
47 return ""
48 }
49 return e.sessions[len(e.sessions)-1]
50 }
51
52 func (e *brokerFakeExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) {
53 e.note(ctx)
54 return e.tabs, nil
55 }
56 func (e *brokerFakeExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
57 e.note(ctx)
58 return browser.Tab{ID: "tab-new", URL: req.URL}, nil
59 }
60 func (e *brokerFakeExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
61 e.note(ctx)
62 return browser.Tab{ID: req.TabID, URL: req.URL}, nil
63 }
64 func (e *brokerFakeExecutor) Snapshot(ctx context.Context, _ browser.SnapshotRequest) (browser.Snapshot, error) {
65 e.note(ctx)
66 return browser.Snapshot{DocumentToken: "doc-1"}, nil
67 }
68 func (e *brokerFakeExecutor) Screenshot(ctx context.Context, _ browser.ScreenshotRequest) (browser.Screenshot, error) {
69 e.note(ctx)
70 return e.screenshot, nil
71 }
72 func (e *brokerFakeExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
73 e.note(ctx)
74 if e.onAct != nil {
75 e.onAct(req)
76 }
77 return browser.ActResult{Executed: true, Outcome: browser.OutcomeExecuted}, nil
78 }
79
80 func (e *brokerFakeExecutor) captureDir() (string, error) {
81 if e.uploadDirectory == "" {
82 return "", errors.New("no upload directory")
83 }
84 return e.uploadDirectory, nil
85 }
86 func (e *brokerFakeExecutor) Downloads(ctx context.Context, _ browser.DownloadsRequest) ([]browser.Download, error) {
87 e.note(ctx)
88 return e.downloads, nil
89 }
90 func (e *brokerFakeExecutor) Close(ctx context.Context, _ browser.CloseRequest) error {
91 e.note(ctx)
92 return nil
93 }
94
95 // brokerTestRig is a running broker with fake resolution, liveness and relay.
96 type brokerTestRig struct {
97 broker *browserBroker
98 baseURL string
99 gen *managedHost
100 // current flips liveness; guarded by mu for the -race runs.
101 mu sync.Mutex
102 live bool
103 conn sftpConn
104 relays []relayCall
105 relayTo string
106 }
107
108 type relayCall struct {
109 workspace string
110 localPath string
111 }
112
113 func newBrokerTestRig(t *testing.T, resolve browserSessionResolver) *brokerTestRig {
114 t.Helper()
115 rig := &brokerTestRig{live: true, gen: &managedHost{}}
116 rig.broker = newBrowserBroker(
117 resolve,
118 func(string, *managedHost) bool { rig.mu.Lock(); defer rig.mu.Unlock(); return rig.live },
119 func(string, *managedHost) sftpConn { return rig.conn },
120 )
121 rig.broker.newRelay = func(sftpConn) FileRelay { return rig }
122 ln, err := net.Listen("tcp", "127.0.0.1:0")
123 if err != nil {
124 t.Fatal(err)
125 }
126 rig.broker.ln = ln
127 rig.broker.port = ln.Addr().(*net.TCPAddr).Port
128 server := &http.Server{Handler: rig.broker}
129 rig.broker.server = server
130 go func() { _ = server.Serve(ln) }()
131 t.Cleanup(func() { rig.broker.close() })
132 rig.baseURL = fmt.Sprintf("http://127.0.0.1:%d", rig.broker.port)
133 return rig
134 }
135
136 // Stage implements FileRelay over the rig, recording the call.
137 func (r *brokerTestRig) Stage(_ context.Context, workspace, localPath string) (string, error) {
138 r.mu.Lock()
139 defer r.mu.Unlock()
140 r.relays = append(r.relays, relayCall{workspace: workspace, localPath: localPath})
141 if r.relayTo == "" {
142 return "", fmt.Errorf("relay unavailable")
143 }
144 return r.relayTo + filepath.Base(localPath), nil
145 }
146
147 func (r *brokerTestRig) Fetch(_ context.Context, workspace, remotePath, localDirectory string) (string, error) {
148 if workspace != "/ws" {
149 return "", fmt.Errorf("wrong workspace %s", workspace)
150 }
151 destination := filepath.Join(localDirectory, filepath.Base(remotePath))
152 return destination, os.WriteFile(destination, []byte("remote bytes: "+remotePath), 0o600)
153 }
154
155 func TestBrowserBrokerUploadStagesRemoteBytesAndCleansUp(t *testing.T) {
156 stagedPaths := make(chan string, 1)
157 exec := &brokerFakeExecutor{uploadDirectory: t.TempDir(), onAct: func(req browser.ActRequest) {
158 if len(req.Files) != 1 || req.Files[0] == "/ws/report.csv" {
159 t.Errorf("remote path reached local executor: %v", req.Files)
160 return
161 }
162 staged := req.Files[0]
163 data, err := os.ReadFile(staged)
164 if err != nil || string(data) != "remote bytes: /ws/report.csv" {
165 t.Errorf("staged upload: %q %v", data, err)
166 }
167 stagedPaths <- staged
168 }}
169 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
170 rig.conn = fakeSFTPConn{}
171 token, _, err := rig.broker.register("host-1", rig.gen)
172 if err != nil {
173 t.Fatal(err)
174 }
175 client := browser.NewHTTPExecutor(rig.baseURL, token, nil)
176 res, err := client.Act(browser.WithSession(context.Background(), "/s"), browser.ActRequest{OperationID: "upload", Action: browser.ActionUpload, Files: []string{"/ws/report.csv"}})
177 if err != nil || !res.Executed {
178 t.Fatalf("upload: %+v %v", res, err)
179 }
180 var staged string
181 select {
182 case staged = <-stagedPaths:
183 default:
184 t.Fatal("no upload dispatched")
185 }
186 if _, err := os.Stat(staged); !os.IsNotExist(err) {
187 t.Fatalf("staging remained after request: %v", err)
188 }
189 }
190
191 type browserGatedBody struct {
192 entered chan struct{}
193 closed chan struct{}
194 once sync.Once
195 }
196
197 func (b *browserGatedBody) Read([]byte) (int, error) {
198 close(b.entered)
199 <-b.closed
200 return 0, io.ErrClosedPipe
201 }
202 func (b *browserGatedBody) Close() error { b.once.Do(func() { close(b.closed) }); return nil }
203
204 func TestBrowserBrokerRevokesRequestWhileBodyIsPending(t *testing.T) {
205 exec := &brokerFakeExecutor{}
206 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
207 token, _, err := rig.broker.register("host-1", rig.gen)
208 if err != nil {
209 t.Fatal(err)
210 }
211 body := &browserGatedBody{entered: make(chan struct{}), closed: make(chan struct{})}
212 req := httptest.NewRequest(http.MethodPost, "/v1/browser/act", body)
213 req.Header.Set("Authorization", "Bearer "+token)
214 req.Header.Set(browser.SessionHeader, "/s")
215 done := make(chan struct{})
216 go func() { defer close(done); rig.broker.ServeHTTP(httptest.NewRecorder(), req) }()
217 <-body.entered
218 rig.broker.revokeHost("host-1")
219 <-done
220 if exec.lastSession() != "" {
221 t.Fatal("revoked request dispatched")
222 }
223 }
224
225 func TestBrowserBrokerRechecksGenerationAfterSessionResolution(t *testing.T) {
226 exec := &brokerFakeExecutor{}
227 entered, release := make(chan struct{}), make(chan struct{})
228 rig := newBrokerTestRig(t, func(string, string) (browserSessionResolution, error) {
229 close(entered)
230 <-release
231 return browserSessionResolution{exec: exec, workspace: "/ws"}, nil
232 })
233 token, _, err := rig.broker.register("host-1", rig.gen)
234 if err != nil {
235 t.Fatal(err)
236 }
237 done := make(chan error, 1)
238 go func() {
239 _, err := browser.NewHTTPExecutor(rig.baseURL, token, nil).Act(browser.WithSession(context.Background(), "/s"), browser.ActRequest{OperationID: "act", Action: browser.ActionClick})
240 done <- err
241 }()
242 <-entered
243 rig.setLive(false)
244 close(release)
245 if err := <-done; !errors.Is(err, browser.ErrNoGrant) {
246 t.Fatalf("superseded request: %v", err)
247 }
248 if exec.lastSession() != "" {
249 t.Fatal("superseded request reached executor")
250 }
251 }
252
253 func (r *brokerTestRig) setLive(live bool) {
254 r.mu.Lock()
255 r.live = live
256 r.mu.Unlock()
257 }
258
259 func (r *brokerTestRig) relayCalls() []relayCall {
260 r.mu.Lock()
261 defer r.mu.Unlock()
262 return append([]relayCall(nil), r.relays...)
263 }
264
265 func brokerTabsCall(t *testing.T, baseURL, token, session string) (int, []browser.Tab) {
266 t.Helper()
267 exec := browser.NewHTTPExecutor(baseURL, token, nil)
268 tabs, err := exec.Tabs(browser.WithSession(context.Background(), session))
269 if err != nil {
270 return statusOfBrokerError(t, baseURL, token, session), nil
271 }
272 return http.StatusOK, tabs
273 }
274
275 // statusOfBrokerError re-issues the call raw to read the status code the
276 // executor mapped into an error.
277 func statusOfBrokerError(t *testing.T, baseURL, token, session string) int {
278 t.Helper()
279 req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/browser/tabs", strings.NewReader(`{}`))
280 if err != nil {
281 t.Fatal(err)
282 }
283 req.Header.Set("Authorization", "Bearer "+token)
284 req.Header.Set("Content-Type", "application/json")
285 if session != "" {
286 req.Header.Set(browser.SessionHeader, session)
287 }
288 resp, err := http.DefaultClient.Do(req)
289 if err != nil {
290 t.Fatal(err)
291 }
292 defer resp.Body.Close()
293 _, _ = io.Copy(io.Discard, resp.Body)
294 return resp.StatusCode
295 }
296
297 func sessionResolver(exec browser.Executor, workspace string, allowed map[string]bool) browserSessionResolver {
298 return func(hostID, sessionPath string) (browserSessionResolution, error) {
299 if !allowed[sessionPath] {
300 return browserSessionResolution{}, fmt.Errorf("%w: no desktop tab serves session %s", browser.ErrNoGrant, sessionPath)
301 }
302 return browserSessionResolution{exec: exec, workspace: workspace}, nil
303 }
304 }
305
306 func TestBrowserBrokerRoundTripRoutesSession(t *testing.T) {
307 exec := &brokerFakeExecutor{tabs: []browser.Tab{{ID: "b1", URL: "https://example.test"}}}
308 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/sessions/a.jsonl": true}))
309 token, _, err := rig.broker.register("host-1", rig.gen)
310 if err != nil {
311 t.Fatal(err)
312 }
313 status, tabs := brokerTabsCall(t, rig.baseURL, token, "/sessions/a.jsonl")
314 if status != http.StatusOK || len(tabs) != 1 || tabs[0].ID != "b1" {
315 t.Fatalf("status=%d tabs=%+v", status, tabs)
316 }
317 if got := exec.lastSession(); got != "/sessions/a.jsonl" {
318 t.Fatalf("executor saw session %q", got)
319 }
320 }
321
322 func TestBrowserBrokerTokenRotationRevokesOldGeneration(t *testing.T) {
323 exec := &brokerFakeExecutor{}
324 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
325 oldToken, _, err := rig.broker.register("host-1", rig.gen)
326 if err != nil {
327 t.Fatal(err)
328 }
329 newGen := &managedHost{}
330 newToken, _, err := rig.broker.register("host-1", newGen)
331 if err != nil {
332 t.Fatal(err)
333 }
334 if oldToken == newToken {
335 t.Fatal("token rotation reused the old token")
336 }
337 if status := statusOfBrokerError(t, rig.baseURL, oldToken, "/s"); status != http.StatusUnauthorized {
338 t.Fatalf("old generation token = %d, want 401", status)
339 }
340 if status := statusOfBrokerError(t, rig.baseURL, newToken, "/s"); status != http.StatusOK {
341 t.Fatalf("new generation token = %d, want 200", status)
342 }
343 }
344
345 func TestBrowserBrokerRejectsDeadGeneration(t *testing.T) {
346 exec := &brokerFakeExecutor{}
347 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
348 token, _, err := rig.broker.register("host-1", rig.gen)
349 if err != nil {
350 t.Fatal(err)
351 }
352 rig.setLive(false)
353 if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized {
354 t.Fatalf("dead generation = %d, want 401", status)
355 }
356 rig.setLive(true)
357 if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusOK {
358 t.Fatalf("live generation = %d, want 200", status)
359 }
360 rig.broker.revokeHost("host-1")
361 if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized {
362 t.Fatalf("revoked host = %d, want 401", status)
363 }
364 }
365
366 func TestBrowserBrokerRejectsForeignAndMissingSessions(t *testing.T) {
367 exec := &brokerFakeExecutor{}
368 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/sessions/a.jsonl": true}))
369 token, _, err := rig.broker.register("host-1", rig.gen)
370 if err != nil {
371 t.Fatal(err)
372 }
373 for _, session := range []string{"/sessions/other.jsonl", ""} {
374 status := statusOfBrokerError(t, rig.baseURL, token, session)
375 if status != http.StatusConflict {
376 t.Fatalf("session %q = %d, want 409 no_grant", session, status)
377 }
378 }
379 if len(exec.sessions) != 0 {
380 t.Fatalf("executor saw %d calls from rejected sessions", len(exec.sessions))
381 }
382 }
383
384 func TestBrowserBrokerRejectsBadToken(t *testing.T) {
385 rig := newBrokerTestRig(t, sessionResolver(&brokerFakeExecutor{}, "/ws", map[string]bool{"/s": true}))
386 if _, _, err := rig.broker.register("host-1", rig.gen); err != nil {
387 t.Fatal(err)
388 }
389 for _, token := range []string{"", "wrong"} {
390 if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized {
391 t.Fatalf("token %q = %d, want 401", token, status)
392 }
393 }
394 req, err := http.NewRequest(http.MethodGet, rig.baseURL+"/healthz", nil)
395 if err != nil {
396 t.Fatal(err)
397 }
398 resp, err := http.DefaultClient.Do(req)
399 if err != nil {
400 t.Fatal(err)
401 }
402 _ = resp.Body.Close()
403 if resp.StatusCode != http.StatusNoContent {
404 t.Fatalf("healthz = %d, want 204", resp.StatusCode)
405 }
406 }
407
408 func TestBrowserBrokerScreenshotRelaysCapture(t *testing.T) {
409 exec := &brokerFakeExecutor{screenshot: browser.Screenshot{Path: "/tmp/reasonix-browser/tab-1/shot.png", MIME: "image/png", Width: 10, Height: 10}}
410 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
411 rig.conn = fakeSFTPConn{}
412 rig.relayTo = "/remote/scratch/"
413 token, _, err := rig.broker.register("host-1", rig.gen)
414 if err != nil {
415 t.Fatal(err)
416 }
417 httpExec := browser.NewHTTPExecutor(rig.baseURL, token, nil)
418 shot, err := httpExec.Screenshot(browser.WithSession(context.Background(), "/s"), browser.ScreenshotRequest{TabID: "tab-1"})
419 if err != nil {
420 t.Fatal(err)
421 }
422 if shot.Path != "/remote/scratch/shot.png" {
423 t.Fatalf("screenshot path = %q, want the relayed remote path", shot.Path)
424 }
425 calls := rig.relayCalls()
426 if len(calls) != 1 || calls[0].workspace != "/ws" || calls[0].localPath != "/tmp/reasonix-browser/tab-1/shot.png" {
427 t.Fatalf("relay calls = %+v", calls)
428 }
429 }
430
431 func TestBrowserBrokerScreenshotWithoutConnectionFails(t *testing.T) {
432 exec := &brokerFakeExecutor{screenshot: browser.Screenshot{Path: "/tmp/shot.png"}}
433 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
434 token, _, err := rig.broker.register("host-1", rig.gen)
435 if err != nil {
436 t.Fatal(err)
437 }
438 httpExec := browser.NewHTTPExecutor(rig.baseURL, token, nil)
439 if _, err := httpExec.Screenshot(browser.WithSession(context.Background(), "/s"), browser.ScreenshotRequest{TabID: "t"}); err == nil {
440 t.Fatal("screenshot without a live connection succeeded")
441 }
442 if calls := rig.relayCalls(); len(calls) != 0 {
443 t.Fatalf("relay ran without a connection: %+v", calls)
444 }
445 }
446
447 func TestBrowserBrokerDownloadsRelayEachPath(t *testing.T) {
448 exec := &brokerFakeExecutor{downloads: []browser.Download{
449 {ID: "d1", Path: "/tmp/dl/a.zip"},
450 {ID: "d2", Path: ""},
451 }}
452 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
453 rig.conn = fakeSFTPConn{}
454 rig.relayTo = "/remote/scratch/"
455 token, _, err := rig.broker.register("host-1", rig.gen)
456 if err != nil {
457 t.Fatal(err)
458 }
459 httpExec := browser.NewHTTPExecutor(rig.baseURL, token, nil)
460 downloads, err := httpExec.Downloads(browser.WithSession(context.Background(), "/s"), browser.DownloadsRequest{TabID: "t"})
461 if err != nil {
462 t.Fatal(err)
463 }
464 if len(downloads) != 2 || downloads[0].Path != "/remote/scratch/a.zip" || downloads[1].Path != "" {
465 t.Fatalf("downloads = %+v", downloads)
466 }
467 if calls := rig.relayCalls(); len(calls) != 1 {
468 t.Fatalf("relay calls = %+v, want exactly the non-empty path", calls)
469 }
470 }
471
472 // fakeSFTPConn satisfies sftpConn without a server; Stage never reaches it in
473 // rig-based tests because newRelay is faked.
474 type fakeSFTPConn struct{}
475
476 func (fakeSFTPConn) SFTP() (*sftpfs.FS, error) { return nil, fmt.Errorf("no sftp") }
477
478 func TestBrowserBrokerConcurrentRotation(t *testing.T) {
479 exec := &brokerFakeExecutor{tabs: []browser.Tab{{ID: "b1"}}}
480 rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true}))
481 token, _, err := rig.broker.register("host-1", rig.gen)
482 if err != nil {
483 t.Fatal(err)
484 }
485 var wg sync.WaitGroup
486 for range 8 {
487 wg.Go(func() {
488 for range 20 {
489 _, _, _ = rig.broker.register("host-1", &managedHost{})
490 }
491 })
492 }
493 for range 8 {
494 wg.Go(func() {
495 for range 20 {
496 _, _ = brokerTabsCall(t, rig.baseURL, token, "/s")
497 }
498 })
499 }
500 wg.Wait()
501 // After the dust settles only the last minted token authenticates.
502 if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized {
503 t.Fatalf("superseded token = %d, want 401", status)
504 }
505 }
506
507 func TestSFTPFileRelayRoundTrip(t *testing.T) {
508 root := t.TempDir()
509 server := sshtest.Start(t, sshtest.Options{Password: "pw", SFTPRoot: root})
510 cl, err := ssh.Dial("tcp", server.Addr, &ssh.ClientConfig{
511 User: "u",
512 Auth: []ssh.AuthMethod{ssh.Password("pw")},
513 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
514 })
515 if err != nil {
516 t.Fatal(err)
517 }
518 defer cl.Close()
519 fs, err := sftpfs.New(cl)
520 if err != nil {
521 t.Fatal(err)
522 }
523 local := filepath.Join(t.TempDir(), "shot.png")
524 if err := os.WriteFile(local, []byte("png-bytes"), 0o600); err != nil {
525 t.Fatal(err)
526 }
527 remotePath, err := (sftpFileRelay{conn: sftpFSConn{fs: fs}}).Stage(context.Background(), "/work space", local)
528 if err != nil {
529 t.Fatal(err)
530 }
531 data, err := os.ReadFile(remotePath)
532 if err != nil {
533 t.Fatalf("relayed file unreadable at %q: %v", remotePath, err)
534 }
535 if string(data) != "png-bytes" {
536 t.Fatalf("relayed content = %q", data)
537 }
538 if !strings.Contains(remotePath, "browser-relay") {
539 t.Fatalf("remote path %q is outside the relay scratch area", remotePath)
540 }
541 info, err := os.Stat(remotePath)
542 if err != nil {
543 t.Fatal(err)
544 }
545 // Windows exposes only the read-only bit through chmod; the Windows
546 // round-trip still checks native file access and both transfer directions.
547 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
548 t.Fatalf("relayed file mode = %v, want 0600", info.Mode().Perm())
549 }
550 // The reverse direction must download the remote bytes and enforce the
551 // remote workspace boundary before handing a desktop path to Chromium.
552 relay := sftpFileRelay{conn: sftpFSConn{fs: fs}}
553 staged, err := relay.Fetch(context.Background(), filepath.Dir(remotePath), remotePath, t.TempDir())
554 if err != nil {
555 t.Fatal(err)
556 }
557 data, err = os.ReadFile(staged)
558 if err != nil || string(data) != "png-bytes" {
559 t.Fatalf("remote upload content = %q %v", data, err)
560 }
561 if _, err := relay.Fetch(context.Background(), filepath.Join(root, "unowned"), local, t.TempDir()); err == nil {
562 t.Fatal("foreign remote file was staged")
563 }
564 }
565
566 type sftpFSConn struct{ fs *sftpfs.FS }
567
568 func (c sftpFSConn) SFTP() (*sftpfs.FS, error) { return c.fs, nil }
569
570 func TestSFTPFileRelayRejectsOddFiles(t *testing.T) {
571 dir := t.TempDir()
572 relay := sftpFileRelay{conn: sftpFSConn{}}
573 if _, err := relay.Stage(context.Background(), "/ws", filepath.Join(dir, "missing.png")); err == nil {
574 t.Fatal("missing file staged")
575 }
576 if _, err := relay.Stage(context.Background(), "/ws", dir); err == nil {
577 t.Fatal("directory staged")
578 }
579 }
580
581 func TestRelayFileNameSanitizes(t *testing.T) {
582 name := relayFileName("/tmp/x/evil.png")
583 if strings.Contains(name, "/") || !strings.HasSuffix(name, "-evil.png") {
584 t.Fatalf("relayFileName = %q", name)
585 }
586 if name == relayFileName("/tmp/x/evil.png") {
587 t.Fatal("relayFileName must be unique per call")
588 }
589 }
590
591 func TestBrowserRelayRemotePathContract(t *testing.T) {
592 for _, tc := range []struct {
593 name, home, agentPath, wirePath string
594 }{
595 {"posix", "/home/user", "/workspace/report.csv", "/workspace/report.csv"},
596 {"posix backslash filename", "/home/user", "/workspace/report\\name.csv", "/workspace/report\\name.csv"},
597 {"posix drive-like directory", "/home/user", "/C:/report.csv", "/C:/report.csv"},
598 {"windows native", "/C:/Users/user", `C:\work space\report.csv`, "/C:/work space/report.csv"},
599 {"windows other drive", "/C:/Users/user", `D:\work space\report.csv`, "/D:/work space/report.csv"},
600 {"windows forward slashes", "/C:/Users/user", "C:/work space/report.csv", "/C:/work space/report.csv"},
601 {"windows canonical", "/C:/Users/user", "/C:/work space/report.csv", "/C:/work space/report.csv"},
602 {"windows unprefixed server", "C:/Users/user", `D:\work space\report.csv`, "D:/work space/report.csv"},
603 } {
604 t.Run(tc.name, func(t *testing.T) {
605 if got := relaySFTPPath(tc.agentPath, tc.home); got != tc.wirePath {
606 t.Fatalf("wire path = %q, want %q", got, tc.wirePath)
607 }
608 if got := relaySFTPPath(relayAgentPath(tc.wirePath, tc.home), tc.home); got != tc.wirePath {
609 t.Fatalf("Agent path round trip = %q, want %q", got, tc.wirePath)
610 }
611 })
612 }
613 for _, wire := range []string{"/C:/Users/user/capture.png", "D:/work/report.csv"} {
614 if got := relayAgentPath(wire, "/C:/Users/user"); !relayWindowsDrivePath(got) || strings.HasPrefix(got, "/") {
615 t.Fatalf("Windows Agent cannot consume %q", got)
616 }
617 }
618 if got := relayAgentPath("/C:/report.csv", "/home/user"); got != "/C:/report.csv" {
619 t.Fatalf("POSIX directory changed to a Windows drive: %q", got)
620 }
621 for _, relative := range []string{"C:report.csv", "report.csv", "../report.csv"} {
622 if relayWindowsDrivePath(relative) {
623 t.Fatalf("relative path accepted as absolute: %q", relative)
624 }
625 }
626 }
627
627 lines GO