github.com/anchore/syft@v1.38.2/syft/source/snapsource/snap_test.go (about) 1 package snapsource 2 3 import ( 4 "context" 5 "crypto" 6 "fmt" 7 "os" 8 "path/filepath" 9 "testing" 10 11 "github.com/spf13/afero" 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/mock" 14 "github.com/stretchr/testify/require" 15 "github.com/wagoodman/go-progress" 16 17 "github.com/anchore/stereoscope/pkg/image" 18 "github.com/anchore/syft/internal/file" 19 ) 20 21 func TestSnapIdentity_String(t *testing.T) { 22 tests := []struct { 23 name string 24 identity snapIdentity 25 expected string 26 }{ 27 { 28 name: "name only", 29 identity: snapIdentity{ 30 Name: "etcd", 31 }, 32 expected: "etcd", 33 }, 34 { 35 name: "name with channel", 36 identity: snapIdentity{ 37 Name: "etcd", 38 Channel: "stable", 39 }, 40 expected: "etcd@stable", 41 }, 42 { 43 name: "name with architecture", 44 identity: snapIdentity{ 45 Name: "etcd", 46 Architecture: "amd64", 47 }, 48 expected: "etcd (amd64)", 49 }, 50 { 51 name: "name with channel and architecture", 52 identity: snapIdentity{ 53 Name: "etcd", 54 Channel: "beta", 55 Architecture: "arm64", 56 }, 57 expected: "etcd@beta (arm64)", 58 }, 59 { 60 name: "empty channel with architecture", 61 identity: snapIdentity{ 62 Name: "mysql", 63 Channel: "", 64 Architecture: "amd64", 65 }, 66 expected: "mysql (amd64)", 67 }, 68 } 69 70 for _, tt := range tests { 71 t.Run(tt.name, func(t *testing.T) { 72 result := tt.identity.String() 73 assert.Equal(t, tt.expected, result) 74 }) 75 } 76 } 77 78 func TestFileExists(t *testing.T) { 79 fs := afero.NewMemMapFs() 80 81 tests := []struct { 82 name string 83 setup func() string 84 expected bool 85 }{ 86 { 87 name: "file exists", 88 setup: func() string { 89 path := "/test/file.snap" 90 require.NoError(t, createMockSquashfsFile(fs, path)) 91 return path 92 }, 93 expected: true, 94 }, 95 { 96 name: "file does not exist", 97 setup: func() string { 98 return "/nonexistent/file.snap" 99 }, 100 expected: false, 101 }, 102 { 103 name: "path is directory", 104 setup: func() string { 105 path := "/test/dir" 106 require.NoError(t, fs.MkdirAll(path, 0755)) 107 return path 108 }, 109 expected: false, 110 }, 111 { 112 name: "file exists in subdirectory", 113 setup: func() string { 114 path := "/deep/nested/path/file.snap" 115 require.NoError(t, createMockSquashfsFile(fs, path)) 116 return path 117 }, 118 expected: true, 119 }, 120 } 121 122 for _, tt := range tests { 123 t.Run(tt.name, func(t *testing.T) { 124 path := tt.setup() 125 result := fileExists(fs, path) 126 assert.Equal(t, tt.expected, result) 127 }) 128 } 129 } 130 131 func TestNewSnapFromFile(t *testing.T) { 132 ctx := context.Background() 133 fs := afero.NewMemMapFs() 134 135 tests := []struct { 136 name string 137 cfg Config 138 setup func() string 139 expectError bool 140 errorMsg string 141 }{ 142 { 143 name: "valid local snap file", 144 cfg: Config{ 145 DigestAlgorithms: []crypto.Hash{crypto.SHA256}, 146 }, 147 setup: func() string { 148 path := "/test/valid.snap" 149 require.NoError(t, createMockSquashfsFile(fs, path)) 150 return path 151 }, 152 expectError: false, 153 }, 154 { 155 name: "architecture specified for local file", 156 cfg: Config{ 157 Platform: &image.Platform{ 158 Architecture: "arm64", 159 }, 160 }, 161 setup: func() string { 162 path := "/test/valid.snap" 163 require.NoError(t, createMockSquashfsFile(fs, path)) 164 return path 165 }, 166 expectError: true, 167 errorMsg: "architecture cannot be specified for local snap files", 168 }, 169 { 170 name: "file does not exist", 171 cfg: Config{}, 172 setup: func() string { 173 return "/nonexistent/file.snap" 174 }, 175 expectError: true, 176 errorMsg: "unable to stat path", 177 }, 178 { 179 name: "path is directory", 180 cfg: Config{}, 181 setup: func() string { 182 path := "/test/directory" 183 require.NoError(t, fs.MkdirAll(path, 0755)) 184 return path 185 }, 186 expectError: true, 187 errorMsg: "given path is a directory", 188 }, 189 } 190 191 for _, tt := range tests { 192 t.Run(tt.name, func(t *testing.T) { 193 path := tt.setup() 194 tt.cfg.Request = path 195 196 result, err := newSnapFromFile(ctx, fs, tt.cfg) 197 198 if tt.expectError { 199 assert.Error(t, err) 200 if tt.errorMsg != "" { 201 assert.Contains(t, err.Error(), tt.errorMsg) 202 } 203 assert.Nil(t, result) 204 } else { 205 assert.NoError(t, err) 206 assert.NotNil(t, result) 207 assert.Equal(t, path, result.Path) 208 assert.NotEmpty(t, result.MimeType) 209 assert.NotEmpty(t, result.Digests) 210 assert.Nil(t, result.Cleanup) // Local files don't have cleanup 211 } 212 }) 213 } 214 } 215 216 func TestNewSnapFileFromRemote(t *testing.T) { 217 ctx := context.Background() 218 219 tests := []struct { 220 name string 221 cfg Config 222 info *remoteSnap 223 setupMock func(*mockFileGetter, afero.Fs) 224 expectError bool 225 errorMsg string 226 validate func(t *testing.T, result *snapFile, fs afero.Fs) 227 }{ 228 { 229 name: "successful remote snap download", 230 cfg: Config{ 231 DigestAlgorithms: []crypto.Hash{crypto.SHA256}, 232 }, 233 info: &remoteSnap{ 234 snapIdentity: snapIdentity{ 235 Name: "etcd", 236 Channel: "stable", 237 Architecture: "amd64", 238 }, 239 URL: "https://api.snapcraft.io/download/etcd_123.snap", 240 }, 241 setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) { 242 mockGetter.On("GetFile", mock.MatchedBy(func(dst string) bool { 243 // expect destination to end with etcd_123.snap 244 return filepath.Base(dst) == "etcd_123.snap" 245 }), "https://api.snapcraft.io/download/etcd_123.snap", mock.Anything).Run(func(args mock.Arguments) { 246 // simulate successful download by creating the file 247 dst := args.String(0) 248 require.NoError(t, createMockSquashfsFile(fs, dst)) 249 }).Return(nil) 250 }, 251 expectError: false, 252 validate: func(t *testing.T, result *snapFile, fs afero.Fs) { 253 assert.NotNil(t, result) 254 assert.Contains(t, result.Path, "etcd_123.snap") 255 assert.NotEmpty(t, result.MimeType) 256 assert.NotEmpty(t, result.Digests) 257 assert.NotNil(t, result.Cleanup) 258 259 _, err := fs.Stat(result.Path) 260 assert.NoError(t, err) 261 262 err = result.Cleanup() 263 require.NoError(t, err) 264 265 _, err = fs.Stat(result.Path) 266 assert.True(t, os.IsNotExist(err)) 267 }, 268 }, 269 { 270 name: "successful download with no digest algorithms", 271 cfg: Config{ 272 DigestAlgorithms: []crypto.Hash{}, // no digests requested 273 }, 274 info: &remoteSnap{ 275 snapIdentity: snapIdentity{ 276 Name: "mysql", 277 Channel: "8.0/stable", 278 Architecture: "arm64", 279 }, 280 URL: "https://api.snapcraft.io/download/mysql_456.snap", 281 }, 282 setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) { 283 mockGetter.On("GetFile", mock.MatchedBy(func(dst string) bool { 284 return filepath.Base(dst) == "mysql_456.snap" 285 }), "https://api.snapcraft.io/download/mysql_456.snap", mock.Anything).Run(func(args mock.Arguments) { 286 dst := args.String(0) 287 require.NoError(t, createMockSquashfsFile(fs, dst)) 288 }).Return(nil) 289 }, 290 expectError: false, 291 validate: func(t *testing.T, result *snapFile, fs afero.Fs) { 292 assert.NotNil(t, result) 293 assert.Contains(t, result.Path, "mysql_456.snap") 294 assert.NotEmpty(t, result.MimeType) 295 assert.Empty(t, result.Digests) // no digests requested 296 assert.NotNil(t, result.Cleanup) 297 }, 298 }, 299 { 300 name: "download fails", 301 cfg: Config{ 302 DigestAlgorithms: []crypto.Hash{crypto.SHA256}, 303 }, 304 info: &remoteSnap{ 305 snapIdentity: snapIdentity{ 306 Name: "failing-snap", 307 Channel: "stable", 308 Architecture: "amd64", 309 }, 310 URL: "https://api.snapcraft.io/download/failing_snap.snap", 311 }, 312 setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) { 313 mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/failing_snap.snap", mock.Anything).Return(fmt.Errorf("network timeout")) 314 }, 315 expectError: true, 316 errorMsg: "failed to download snap file", 317 }, 318 } 319 320 for _, tt := range tests { 321 t.Run(tt.name, func(t *testing.T) { 322 fs := afero.NewOsFs() 323 mockGetter := &mockFileGetter{} 324 325 if tt.setupMock != nil { 326 tt.setupMock(mockGetter, fs) 327 } 328 329 result, err := newSnapFileFromRemote(ctx, fs, tt.cfg, mockGetter, tt.info) 330 331 if tt.expectError { 332 require.Error(t, err) 333 if tt.errorMsg != "" { 334 assert.Contains(t, err.Error(), tt.errorMsg) 335 } 336 assert.Nil(t, result) 337 } else { 338 require.NoError(t, err) 339 if tt.validate != nil { 340 tt.validate(t, result, fs) 341 } 342 } 343 344 mockGetter.AssertExpectations(t) 345 }) 346 } 347 } 348 349 func TestGetSnapFileInfo(t *testing.T) { 350 ctx := context.Background() 351 fs := afero.NewMemMapFs() 352 353 tests := []struct { 354 name string 355 setup func() string 356 hashes []crypto.Hash 357 expectError bool 358 errorMsg string 359 }{ 360 { 361 name: "valid squashfs file with hashes", 362 setup: func() string { 363 path := "/test/valid.snap" 364 require.NoError(t, createMockSquashfsFile(fs, path)) 365 return path 366 }, 367 hashes: []crypto.Hash{crypto.SHA256, crypto.MD5}, 368 expectError: false, 369 }, 370 { 371 name: "valid squashfs file without hashes", 372 setup: func() string { 373 path := "/test/valid.snap" 374 require.NoError(t, createMockSquashfsFile(fs, path)) 375 return path 376 }, 377 hashes: []crypto.Hash{}, 378 expectError: false, 379 }, 380 { 381 name: "file does not exist", 382 setup: func() string { 383 return "/nonexistent/file.snap" 384 }, 385 expectError: true, 386 errorMsg: "unable to stat path", 387 }, 388 { 389 name: "path is directory", 390 setup: func() string { 391 path := "/test/directory" 392 require.NoError(t, fs.MkdirAll(path, 0755)) 393 return path 394 }, 395 expectError: true, 396 errorMsg: "given path is a directory", 397 }, 398 { 399 name: "invalid file format", 400 setup: func() string { 401 path := "/test/invalid.txt" 402 require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755)) 403 file, err := fs.Create(path) 404 require.NoError(t, err) 405 defer file.Close() 406 _, err = file.Write([]byte("not a squashfs file")) 407 require.NoError(t, err) 408 return path 409 }, 410 expectError: true, 411 errorMsg: "not a valid squashfs/snap file", 412 }, 413 } 414 415 for _, tt := range tests { 416 t.Run(tt.name, func(t *testing.T) { 417 path := tt.setup() 418 419 mimeType, digests, err := getSnapFileInfo(ctx, fs, path, tt.hashes) 420 421 if tt.expectError { 422 assert.Error(t, err) 423 if tt.errorMsg != "" { 424 assert.Contains(t, err.Error(), tt.errorMsg) 425 } 426 } else { 427 assert.NoError(t, err) 428 assert.NotEmpty(t, mimeType) 429 if len(tt.hashes) > 0 { 430 assert.Len(t, digests, len(tt.hashes)) 431 } else { 432 assert.Empty(t, digests) 433 } 434 } 435 }) 436 } 437 } 438 439 func TestDownloadSnap(t *testing.T) { 440 mockGetter := &mockFileGetter{} 441 442 tests := []struct { 443 name string 444 info *remoteSnap 445 dest string 446 setupMock func() 447 expectError bool 448 errorMsg string 449 }{ 450 { 451 name: "successful download", 452 info: &remoteSnap{ 453 snapIdentity: snapIdentity{ 454 Name: "etcd", 455 Channel: "stable", 456 Architecture: "amd64", 457 }, 458 URL: "https://example.com/etcd.snap", 459 }, 460 dest: "/tmp/etcd.snap", 461 setupMock: func() { 462 mockGetter.On("GetFile", "/tmp/etcd.snap", "https://example.com/etcd.snap", mock.AnythingOfType("[]*progress.Manual")).Return(nil) 463 }, 464 expectError: false, 465 }, 466 { 467 name: "download fails", 468 info: &remoteSnap{ 469 snapIdentity: snapIdentity{ 470 Name: "etcd", 471 Channel: "stable", 472 Architecture: "amd64", 473 }, 474 URL: "https://example.com/etcd.snap", 475 }, 476 dest: "/tmp/etcd.snap", 477 setupMock: func() { 478 mockGetter.On("GetFile", "/tmp/etcd.snap", "https://example.com/etcd.snap", mock.AnythingOfType("[]*progress.Manual")).Return(fmt.Errorf("network error")) 479 }, 480 expectError: true, 481 errorMsg: "failed to download snap file", 482 }, 483 } 484 485 for _, tt := range tests { 486 t.Run(tt.name, func(t *testing.T) { 487 // reset mock for each test 488 mockGetter.ExpectedCalls = nil 489 if tt.setupMock != nil { 490 tt.setupMock() 491 } 492 493 err := downloadSnap(mockGetter, tt.info, tt.dest) 494 495 if tt.expectError { 496 assert.Error(t, err) 497 if tt.errorMsg != "" { 498 assert.Contains(t, err.Error(), tt.errorMsg) 499 } 500 } else { 501 assert.NoError(t, err) 502 } 503 504 mockGetter.AssertExpectations(t) 505 }) 506 } 507 } 508 509 func TestParseSnapRequest(t *testing.T) { 510 tests := []struct { 511 name string 512 request string 513 expectedName string 514 expectedChannel string 515 }{ 516 { 517 name: "snap name only - uses default channel", 518 request: "etcd", 519 expectedName: "etcd", 520 expectedChannel: "stable", 521 }, 522 { 523 name: "snap with beta channel", 524 request: "etcd@beta", 525 expectedName: "etcd", 526 expectedChannel: "beta", 527 }, 528 { 529 name: "snap with edge channel", 530 request: "etcd@edge", 531 expectedName: "etcd", 532 expectedChannel: "edge", 533 }, 534 { 535 name: "snap with version track", 536 request: "etcd@2.3/stable", 537 expectedName: "etcd", 538 expectedChannel: "2.3/stable", 539 }, 540 { 541 name: "snap with complex channel path", 542 request: "mysql@8.0/candidate", 543 expectedName: "mysql", 544 expectedChannel: "8.0/candidate", 545 }, 546 { 547 name: "snap with multiple @ symbols - only first is delimiter", 548 request: "app@beta@test", 549 expectedName: "app", 550 expectedChannel: "beta@test", 551 }, 552 { 553 name: "empty snap name with channel", 554 request: "@stable", 555 expectedName: "", 556 expectedChannel: "stable", 557 }, 558 { 559 name: "snap name with empty channel - uses default", 560 request: "etcd@", 561 expectedName: "etcd", 562 expectedChannel: "stable", 563 }, 564 { 565 name: "hyphenated snap name", 566 request: "hello-world@stable", 567 expectedName: "hello-world", 568 expectedChannel: "stable", 569 }, 570 { 571 name: "snap name with numbers", 572 request: "app123", 573 expectedName: "app123", 574 expectedChannel: "stable", 575 }, 576 } 577 578 for _, tt := range tests { 579 t.Run(tt.name, func(t *testing.T) { 580 name, channel := parseSnapRequest(tt.request) 581 assert.Equal(t, tt.expectedName, name) 582 assert.Equal(t, tt.expectedChannel, channel) 583 }) 584 } 585 } 586 587 type mockFileGetter struct { 588 mock.Mock 589 file.Getter 590 } 591 592 func (m *mockFileGetter) GetFile(dst, src string, monitor ...*progress.Manual) error { 593 args := m.Called(dst, src, monitor) 594 return args.Error(0) 595 } 596 597 func createMockSquashfsFile(fs afero.Fs, path string) error { 598 dir := filepath.Dir(path) 599 if err := fs.MkdirAll(dir, 0755); err != nil { 600 return err 601 } 602 603 file, err := fs.Create(path) 604 if err != nil { 605 return err 606 } 607 defer file.Close() 608 609 // write squashfs magic header 610 _, err = file.Write([]byte("hsqs")) 611 return err 612 }