返回 DeepSeek-Reasonix
attachments_test.go
根目录 / internal / control / attachments_test.go
1 package control
2
3 import (
4 "encoding/base64"
5 "errors"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12 )
13
14 const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
15
16 func TestSaveImageDataURL(t *testing.T) {
17 t.Chdir(t.TempDir())
18 got, err := SaveImageDataURL("data:image/png;base64," + tinyPNG)
19 if err != nil {
20 t.Fatalf("SaveImageDataURL: %v", err)
21 }
22 if !strings.HasPrefix(got, ".reasonix/attachments/clipboard-") || !strings.HasSuffix(got, ".png") {
23 t.Fatalf("path = %q, want attachment png path", got)
24 }
25 }
26
27 func TestSaveImageDataURLRejectsSpoofedMime(t *testing.T) {
28 t.Chdir(t.TempDir())
29 if _, err := SaveImageDataURL("data:image/png;base64,aGk="); err == nil {
30 t.Fatal("spoofed image mime should fail")
31 }
32 }
33
34 func TestCreateAttachmentFileSkipsExistingPath(t *testing.T) {
35 t.Chdir(t.TempDir())
36 if err := ensureAttachmentRoot(); err != nil {
37 t.Fatal(err)
38 }
39
40 first := attachmentPath(".png")
41 if err := os.WriteFile(first, []byte("keep"), 0o644); err != nil {
42 t.Fatal(err)
43 }
44
45 rel, f, err := createAttachmentFile(".png")
46 if err != nil {
47 t.Fatalf("createAttachmentFile: %v", err)
48 }
49 if err := f.Close(); err != nil {
50 t.Fatal(err)
51 }
52 if rel == first {
53 t.Fatalf("createAttachmentFile reused existing path %q", rel)
54 }
55 if got, err := os.ReadFile(first); err != nil {
56 t.Fatal(err)
57 } else if string(got) != "keep" {
58 t.Fatalf("existing attachment was overwritten: %q", got)
59 }
60 }
61
62 func TestSaveImageBytesUsesUniquePathsWithinSameTimestamp(t *testing.T) {
63 t.Chdir(t.TempDir())
64 oldNow := attachmentNow
65 attachmentNow = func() time.Time {
66 return time.Date(2026, 6, 1, 10, 20, 30, 123456000, time.UTC)
67 }
68 defer func() {
69 attachmentNow = oldNow
70 }()
71
72 raw := mustBase64(t, tinyPNG)
73 first, err := SaveImageBytes("image/png", raw)
74 if err != nil {
75 t.Fatalf("first SaveImageBytes: %v", err)
76 }
77 second, err := SaveImageBytes("image/png", raw)
78 if err != nil {
79 t.Fatalf("second SaveImageBytes: %v", err)
80 }
81 if first == second {
82 t.Fatalf("paths collided: %q", first)
83 }
84 for _, path := range []string{first, second} {
85 if got, err := os.ReadFile(path); err != nil {
86 t.Fatalf("read %s: %v", path, err)
87 } else if string(got) != string(raw) {
88 t.Fatalf("content for %s changed", path)
89 }
90 }
91 }
92
93 func TestSaveImageFile(t *testing.T) {
94 t.Chdir(t.TempDir())
95 if err := os.WriteFile("source.png", mustBase64(t, tinyPNG), 0o644); err != nil {
96 t.Fatal(err)
97 }
98 got, err := SaveImageFile("source.png")
99 if err != nil {
100 t.Fatalf("SaveImageFile: %v", err)
101 }
102 if !strings.HasPrefix(got, ".reasonix/attachments/clipboard-") || !strings.HasSuffix(got, ".png") {
103 t.Fatalf("path = %q, want attachment png path", got)
104 }
105 }
106
107 func TestSaveAttachmentFile(t *testing.T) {
108 t.Chdir(t.TempDir())
109 if err := os.WriteFile("notes.pdf", []byte("%PDF-1.4 body"), 0o644); err != nil {
110 t.Fatal(err)
111 }
112 got, err := SaveAttachmentFile("notes.pdf")
113 if err != nil {
114 t.Fatalf("SaveAttachmentFile: %v", err)
115 }
116 if !strings.HasPrefix(got, ".reasonix/attachments/clipboard-") || !strings.HasSuffix(got, ".pdf") {
117 t.Fatalf("path = %q, want attachment pdf path", got)
118 }
119 if data, err := os.ReadFile(got); err != nil || string(data) != "%PDF-1.4 body" {
120 t.Fatalf("stored bytes = %q (err %v), want original", data, err)
121 }
122 }
123
124 func TestSaveAttachmentFileRejectsEmptyAndDir(t *testing.T) {
125 t.Chdir(t.TempDir())
126 if err := os.WriteFile("empty.txt", nil, 0o644); err != nil {
127 t.Fatal(err)
128 }
129 if _, err := SaveAttachmentFile("empty.txt"); err == nil {
130 t.Fatal("empty file should fail")
131 }
132 if err := os.Mkdir("adir", 0o755); err != nil {
133 t.Fatal(err)
134 }
135 if _, err := SaveAttachmentFile("adir"); err == nil {
136 t.Fatal("directory should fail")
137 }
138 }
139
140 func TestSaveAttachmentFileSanitizesExtension(t *testing.T) {
141 t.Chdir(t.TempDir())
142 if err := os.WriteFile("payload.weird-ext-here", []byte("x"), 0o644); err != nil {
143 t.Fatal(err)
144 }
145 got, err := SaveAttachmentFile("payload.weird-ext-here")
146 if err != nil {
147 t.Fatalf("SaveAttachmentFile: %v", err)
148 }
149 if !strings.HasSuffix(got, ".bin") {
150 t.Fatalf("path = %q, want .bin fallback for unsafe extension", got)
151 }
152 }
153
154 func TestSaveAttachmentFileRejectsSymlink(t *testing.T) {
155 t.Chdir(t.TempDir())
156 if err := os.WriteFile("source.bin", []byte("payload"), 0o644); err != nil {
157 t.Fatal(err)
158 }
159 if err := os.Symlink("source.bin", "link.bin"); err != nil {
160 t.Skipf("symlink unsupported: %v", err)
161 }
162 if _, err := SaveAttachmentFile("link.bin"); err == nil {
163 t.Fatal("symlink attachment path should fail")
164 }
165 }
166
167 func TestSaveImageFileRejectsSymlink(t *testing.T) {
168 t.Chdir(t.TempDir())
169 if err := os.WriteFile("source.png", mustBase64(t, tinyPNG), 0o644); err != nil {
170 t.Fatal(err)
171 }
172 if err := os.Symlink("source.png", "link.png"); err != nil {
173 t.Skipf("symlink unsupported: %v", err)
174 }
175 if _, err := SaveImageFile("link.png"); err == nil {
176 t.Fatal("symlink image path should fail")
177 }
178 }
179
180 func TestImageDataURLRejectsOutsideAttachmentDir(t *testing.T) {
181 t.Chdir(t.TempDir())
182 if err := os.WriteFile("x.png", []byte("x"), 0o644); err != nil {
183 t.Fatal(err)
184 }
185 if _, err := ImageDataURL("x.png"); err == nil {
186 t.Fatal("outside attachment dir should fail")
187 }
188 if _, err := ImageDataURL("../.reasonix/attachments/x.png"); err == nil {
189 t.Fatal("traversal path should fail")
190 }
191 }
192
193 func TestImageDataURLInRootIgnoresProcessWorkingDirectory(t *testing.T) {
194 workspace := t.TempDir()
195 processDir := t.TempDir()
196 path, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG))
197 if err != nil {
198 t.Fatalf("SaveImageBytesInRoot: %v", err)
199 }
200 t.Chdir(processDir)
201 got, err := ImageDataURLInRoot(workspace, path)
202 if err != nil {
203 t.Fatalf("ImageDataURLInRoot: %v", err)
204 }
205 if !strings.HasPrefix(got, "data:image/png;base64,") {
206 t.Fatalf("data URL = %q, want png", got)
207 }
208 if _, err := os.Stat(filepath.Join(processDir, ".reasonix")); !os.IsNotExist(err) {
209 t.Fatalf("read created process-local attachment state: %v", err)
210 }
211 }
212
213 func TestAttachmentRootAPIsRejectManagedDirectorySymlinks(t *testing.T) {
214 for _, component := range []string{".reasonix", "attachments"} {
215 t.Run(component, func(t *testing.T) {
216 workspace := t.TempDir()
217 outside := t.TempDir()
218 var link string
219 if component == ".reasonix" {
220 if err := os.MkdirAll(filepath.Join(outside, "attachments"), 0o755); err != nil {
221 t.Fatal(err)
222 }
223 link = filepath.Join(workspace, ".reasonix")
224 } else {
225 if err := os.MkdirAll(filepath.Join(workspace, ".reasonix"), 0o755); err != nil {
226 t.Fatal(err)
227 }
228 link = filepath.Join(workspace, ".reasonix", "attachments")
229 }
230 if err := os.Symlink(outside, link); err != nil {
231 t.Skipf("symlink unsupported: %v", err)
232 }
233 if _, err := ImageDataURLInRoot(workspace, ".reasonix/attachments/image.png"); err == nil || !strings.Contains(err.Error(), "symlink") {
234 t.Fatalf("read through managed directory symlink = %v, want symlink rejection", err)
235 }
236 if _, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG)); err == nil || !strings.Contains(err.Error(), "symlink") {
237 t.Fatalf("write through managed directory symlink = %v, want symlink rejection", err)
238 }
239 })
240 }
241 }
242
243 func TestImageDataURLInRootKeepsSameRelativeNameIsolated(t *testing.T) {
244 firstRoot := t.TempDir()
245 secondRoot := t.TempDir()
246 rel := filepath.Join(".reasonix", "attachments", "same.png")
247 for _, root := range []string{firstRoot, secondRoot} {
248 if err := os.MkdirAll(filepath.Join(root, filepath.Dir(rel)), 0o755); err != nil {
249 t.Fatal(err)
250 }
251 }
252 first := mustBase64(t, tinyPNG)
253 second := append([]byte(nil), first...)
254 second[len(second)-1] ^= 1
255 if err := os.WriteFile(filepath.Join(firstRoot, rel), first, 0o644); err != nil {
256 t.Fatal(err)
257 }
258 if err := os.WriteFile(filepath.Join(secondRoot, rel), second, 0o644); err != nil {
259 t.Fatal(err)
260 }
261 one, err := ImageDataURLInRoot(firstRoot, filepath.ToSlash(rel))
262 if err != nil {
263 t.Fatal(err)
264 }
265 two, err := ImageDataURLInRoot(secondRoot, filepath.ToSlash(rel))
266 if err != nil {
267 t.Fatal(err)
268 }
269 if one == two {
270 t.Fatal("same attachment name in two workspaces resolved to the same bytes")
271 }
272 }
273
274 func TestImageDataURLInRootDoesNotCreateMissingAttachmentDirectory(t *testing.T) {
275 workspace := t.TempDir()
276 _, err := ImageDataURLInRoot(workspace, ".reasonix/attachments/missing.png")
277 if !errors.Is(err, os.ErrNotExist) {
278 t.Fatalf("error = %v, want not exist", err)
279 }
280 if _, err := os.Stat(filepath.Join(workspace, ".reasonix")); !os.IsNotExist(err) {
281 t.Fatalf("read created attachment directory: %v", err)
282 }
283 }
284
285 func TestImageDataURLRejectsSymlinkFile(t *testing.T) {
286 t.Chdir(t.TempDir())
287 if err := ensureAttachmentRoot(); err != nil {
288 t.Fatal(err)
289 }
290 if err := os.WriteFile("secret.png", []byte("secret"), 0o644); err != nil {
291 t.Fatal(err)
292 }
293 link := filepath.Join(".reasonix", "attachments", "link.png")
294 if err := os.Symlink(filepath.Join("..", "..", "secret.png"), link); err != nil {
295 t.Skipf("symlink unsupported: %v", err)
296 }
297 if _, err := ImageDataURL(link); err == nil {
298 t.Fatal("symlink attachment file should fail")
299 }
300 }
301
302 func TestImageDataURLRejectsSymlinkAttachmentDir(t *testing.T) {
303 t.Chdir(t.TempDir())
304 if err := os.Mkdir(".reasonix", 0o755); err != nil {
305 t.Fatal(err)
306 }
307 if err := os.Mkdir("elsewhere", 0o755); err != nil {
308 t.Fatal(err)
309 }
310 if err := os.Symlink("../elsewhere", filepath.Join(".reasonix", "attachments")); err != nil {
311 t.Skipf("symlink unsupported: %v", err)
312 }
313 if _, err := ImageDataURL(".reasonix/attachments/x.png"); err == nil {
314 t.Fatal("symlink attachment directory should fail")
315 }
316 }
317
318 func TestImageDataURLRejectsSymlinkSubdirectory(t *testing.T) {
319 t.Chdir(t.TempDir())
320 if err := ensureAttachmentRoot(); err != nil {
321 t.Fatal(err)
322 }
323 if err := os.Mkdir("outside", 0o755); err != nil {
324 t.Fatal(err)
325 }
326 if err := os.WriteFile(filepath.Join("outside", "x.png"), mustBase64(t, tinyPNG), 0o644); err != nil {
327 t.Fatal(err)
328 }
329 link := filepath.Join(".reasonix", "attachments", "link")
330 if err := os.Symlink(filepath.Join("..", "..", "outside"), link); err != nil {
331 t.Skipf("symlink unsupported: %v", err)
332 }
333 if _, err := ImageDataURL(filepath.Join(link, "x.png")); err == nil {
334 t.Fatal("symlink attachment subdirectory should fail")
335 }
336 }
337
338 func TestValidateAttachmentInRootRejectsMissingAndSymlink(t *testing.T) {
339 root := t.TempDir()
340 rel, err := SaveAttachmentBytesInRoot(root, "notes.txt", []byte("saved"))
341 if err != nil {
342 t.Fatal(err)
343 }
344 if err := ValidateAttachmentInRoot(root, rel); err != nil {
345 t.Fatalf("valid attachment: %v", err)
346 }
347 if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil {
348 t.Fatal(err)
349 }
350 if err := ValidateAttachmentInRoot(root, rel); err == nil {
351 t.Fatal("missing attachment should fail validation")
352 }
353 outside := filepath.Join(root, "outside.txt")
354 if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
355 t.Fatal(err)
356 }
357 link := filepath.Join(root, ".reasonix", "attachments", "link.txt")
358 if err := os.Symlink(outside, link); err != nil {
359 t.Skipf("symlink unsupported: %v", err)
360 }
361 if err := ValidateAttachmentInRoot(root, filepath.ToSlash(filepath.Join(".reasonix", "attachments", "link.txt"))); err == nil {
362 t.Fatal("symlink attachment should fail validation")
363 }
364 }
365
366 func mustBase64(t *testing.T, s string) []byte {
367 t.Helper()
368 raw, err := base64.StdEncoding.DecodeString(s)
369 if err != nil {
370 t.Fatal(err)
371 }
372 return raw
373 }
374
375 func stubClipboardTools(t *testing.T, look func(string) (string, error), run func(string, ...string) ([]byte, []byte, error)) {
376 t.Helper()
377 previousLook := lookClipboardTool
378 previousRun := runClipboardTool
379 t.Cleanup(func() {
380 lookClipboardTool = previousLook
381 runClipboardTool = previousRun
382 })
383 lookClipboardTool = look
384 runClipboardTool = run
385 }
386
387 func TestSaveLinuxClipboardImageSeparatesNoImageFromMissingTools(t *testing.T) {
388 t.Chdir(t.TempDir())
389 stubClipboardTools(t,
390 func(string) (string, error) { return "", exec.ErrNotFound },
391 func(string, ...string) ([]byte, []byte, error) {
392 t.Fatal("missing tools must not run")
393 return nil, nil, nil
394 },
395 )
396 _, err := saveLinuxClipboardImage()
397 if err == nil || errors.Is(err, ErrNoClipboardImage) {
398 t.Fatalf("missing tools reported as an empty clipboard: %v", err)
399 }
400 if !strings.Contains(err.Error(), "needs wl-paste") {
401 t.Fatalf("missing tools lost their actionable message: %v", err)
402 }
403
404 stubClipboardTools(t,
405 func(name string) (string, error) {
406 if name == "wl-paste" {
407 return name, nil
408 }
409 return "", exec.ErrNotFound
410 },
411 func(_ string, args ...string) ([]byte, []byte, error) {
412 if len(args) != 1 || args[0] != "--list-types" {
413 t.Fatalf("text clipboard unexpectedly read as an image: %v", args)
414 }
415 return []byte("text/plain\nUTF8_STRING\n"), nil, nil
416 },
417 )
418 if _, err := saveLinuxClipboardImage(); !errors.Is(err, ErrNoClipboardImage) {
419 t.Fatalf("text-only clipboard reported as a broken setup: %v", err)
420 }
421 }
422
423 func TestSaveLinuxClipboardImagePreservesProbeFailure(t *testing.T) {
424 stubClipboardTools(t,
425 func(name string) (string, error) {
426 if name == "wl-paste" {
427 return name, nil
428 }
429 return "", exec.ErrNotFound
430 },
431 func(string, ...string) ([]byte, []byte, error) {
432 return nil, []byte("failed to connect to display"), errors.New("display unavailable")
433 },
434 )
435
436 _, err := saveLinuxClipboardImage()
437 if err == nil || errors.Is(err, ErrNoClipboardImage) {
438 t.Fatalf("clipboard probe failure reported as no image: %v", err)
439 }
440 if !strings.Contains(err.Error(), "probe wl-paste clipboard types") {
441 t.Fatalf("clipboard probe failure lost its operation: %v", err)
442 }
443 }
444
445 func TestSaveLinuxClipboardImagePreservesImageReadFailure(t *testing.T) {
446 stubClipboardTools(t,
447 func(name string) (string, error) {
448 if name == "wl-paste" {
449 return name, nil
450 }
451 return "", exec.ErrNotFound
452 },
453 func(_ string, args ...string) ([]byte, []byte, error) {
454 if len(args) == 1 && args[0] == "--list-types" {
455 return []byte("text/plain\nimage/png\n"), nil, nil
456 }
457 return nil, nil, errors.New("selection changed")
458 },
459 )
460
461 _, err := saveLinuxClipboardImage()
462 if err == nil || errors.Is(err, ErrNoClipboardImage) {
463 t.Fatalf("clipboard image read failure reported as no image: %v", err)
464 }
465 if !strings.Contains(err.Error(), "read clipboard image with wl-paste") {
466 t.Fatalf("clipboard image read failure lost its operation: %v", err)
467 }
468 }
469
470 func TestSaveLinuxClipboardImageTreatsEmptySelectionAsNoImage(t *testing.T) {
471 stubClipboardTools(t,
472 func(name string) (string, error) {
473 if name == "wl-paste" {
474 return name, nil
475 }
476 return "", exec.ErrNotFound
477 },
478 func(string, ...string) ([]byte, []byte, error) {
479 return nil, []byte("Nothing is copied\n"), errors.New("exit status 1")
480 },
481 )
482
483 if _, err := saveLinuxClipboardImage(); !errors.Is(err, ErrNoClipboardImage) {
484 t.Fatalf("empty clipboard = %v, want ErrNoClipboardImage", err)
485 }
486 }
487
488 func TestSaveDarwinClipboardImagePreservesOperationalFailure(t *testing.T) {
489 want := errors.New("attachment directory unavailable")
490 _, err := saveDarwinClipboardImageWith(func(string) (string, error) {
491 return "", want
492 })
493 if !errors.Is(err, want) {
494 t.Fatalf("darwin clipboard operational failure = %v, want %v", err, want)
495 }
496 }
497
498 func TestSaveDarwinClipboardImageReturnsNoImageOnlyAfterBothTypesMiss(t *testing.T) {
499 var classes []string
500 _, err := saveDarwinClipboardImageWith(func(class string) (string, error) {
501 classes = append(classes, class)
502 return "", ErrNoClipboardImage
503 })
504 if !errors.Is(err, ErrNoClipboardImage) {
505 t.Fatalf("darwin empty clipboard = %v, want ErrNoClipboardImage", err)
506 }
507 if got, want := strings.Join(classes, ","), "PNGf,JPEG"; got != want {
508 t.Fatalf("darwin clipboard classes = %q, want %q", got, want)
509 }
510 }
511
512 func TestClassifyDarwinClipboardResultDistinguishesMissingTypeFromFailure(t *testing.T) {
513 const marker = "__NO_IMAGE__"
514 if err := classifyDarwinClipboardResult([]byte(marker+"\n"), nil, marker); !errors.Is(err, ErrNoClipboardImage) {
515 t.Fatalf("darwin no-image marker = %v, want ErrNoClipboardImage", err)
516 }
517
518 want := errors.New("osascript failed")
519 err := classifyDarwinClipboardResult([]byte("clipboard service unavailable\n"), want, marker)
520 if !errors.Is(err, want) || errors.Is(err, ErrNoClipboardImage) {
521 t.Fatalf("darwin operational failure = %v, want wrapped %v", err, want)
522 }
523 }
524
525 func TestSaveLinuxClipboardImageNegotiatesSupportedImageType(t *testing.T) {
526 t.Chdir(t.TempDir())
527 png, err := base64.StdEncoding.DecodeString(tinyPNG)
528 if err != nil {
529 t.Fatal(err)
530 }
531 var readArgs []string
532 stubClipboardTools(t,
533 func(name string) (string, error) {
534 if name == "wl-paste" {
535 return name, nil
536 }
537 return "", exec.ErrNotFound
538 },
539 func(_ string, args ...string) ([]byte, []byte, error) {
540 if len(args) == 1 && args[0] == "--list-types" {
541 return []byte("text/plain\nimage/jpeg\n"), nil, nil
542 }
543 readArgs = args
544 return png, nil, nil
545 },
546 )
547 if _, err := saveLinuxClipboardImage(); err != nil {
548 t.Fatal(err)
549 }
550 if got, want := strings.Join(readArgs, " "), "--type image/jpeg --no-newline"; got != want {
551 t.Fatalf("read args = %q, want %q", got, want)
552 }
553 }
554
555 func TestSaveLinuxClipboardImageNamesUnsupportedImageTypes(t *testing.T) {
556 stubClipboardTools(t,
557 func(name string) (string, error) {
558 if name == "wl-paste" {
559 return name, nil
560 }
561 return "", exec.ErrNotFound
562 },
563 func(_ string, args ...string) ([]byte, []byte, error) {
564 if len(args) == 1 && args[0] == "--list-types" {
565 return []byte("text/plain\nimage/bmp\nimage/\x1b]52;c;owned\a\n"), nil, nil
566 }
567 t.Fatalf("unsupported image types must not be read: %v", args)
568 return nil, nil, nil
569 },
570 )
571 _, err := saveLinuxClipboardImage()
572 if !errors.Is(err, ErrNoClipboardImage) {
573 t.Fatalf("unsupported image error = %v, want ErrNoClipboardImage fallback", err)
574 }
575 if !errors.Is(err, ErrUnsupportedClipboardImage) {
576 t.Fatalf("unsupported image error lost its diagnostic type: %v", err)
577 }
578 if !strings.Contains(err.Error(), "image/bmp") || !strings.Contains(err.Error(), "unsupported") {
579 t.Fatalf("error should name the unsupported image type: %v", err)
580 }
581 if strings.ContainsAny(err.Error(), "\x1b\a") {
582 t.Fatalf("error contains clipboard-provided terminal controls: %q", err)
583 }
584 }
585
585 lines GO