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  }