| 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 |