github.com/anchore/syft@v1.38.2/syft/format/syftjson/to_format_model_test.go (about)

     1  package syftjson
     2  
     3  import (
     4  	"encoding/json"
     5  	"testing"
     6  
     7  	"github.com/google/go-cmp/cmp"
     8  	"github.com/google/go-cmp/cmp/cmpopts"
     9  	"github.com/stretchr/testify/assert"
    10  	"github.com/stretchr/testify/require"
    11  
    12  	stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
    13  	"github.com/anchore/syft/internal/sourcemetadata"
    14  	"github.com/anchore/syft/syft/file"
    15  	"github.com/anchore/syft/syft/format/syftjson/model"
    16  	"github.com/anchore/syft/syft/pkg"
    17  	"github.com/anchore/syft/syft/source"
    18  )
    19  
    20  func Test_toSourceModel_IgnoreBase(t *testing.T) {
    21  	tests := []struct {
    22  		name string
    23  		src  source.Description
    24  	}{
    25  		{
    26  			name: "directory",
    27  			src: source.Description{
    28  				ID: "test-id",
    29  				Metadata: source.DirectoryMetadata{
    30  					Path: "some/path",
    31  					Base: "some/base",
    32  				},
    33  			},
    34  		},
    35  	}
    36  	for _, test := range tests {
    37  		t.Run(test.name, func(t *testing.T) {
    38  			// assert the model transformation is correct
    39  			actual := toSourceModel(test.src)
    40  
    41  			by, err := json.Marshal(actual)
    42  			require.NoError(t, err)
    43  			assert.NotContains(t, string(by), "some/base")
    44  		})
    45  	}
    46  }
    47  
    48  func Test_toSourceModel(t *testing.T) {
    49  	tracker := sourcemetadata.NewCompletionTester(t)
    50  
    51  	tests := []struct {
    52  		name     string
    53  		src      source.Description
    54  		expected model.Source
    55  	}{
    56  		{
    57  			name: "directory",
    58  			src: source.Description{
    59  				ID:       "test-id",
    60  				Name:     "some-name",
    61  				Version:  "some-version",
    62  				Supplier: "optional-supplier",
    63  				Metadata: source.DirectoryMetadata{
    64  					Path: "some/path",
    65  					Base: "some/base",
    66  				},
    67  			},
    68  			expected: model.Source{
    69  				ID:       "test-id",
    70  				Name:     "some-name",
    71  				Version:  "some-version",
    72  				Supplier: "optional-supplier",
    73  				Type:     "directory",
    74  				Metadata: source.DirectoryMetadata{
    75  					Path: "some/path",
    76  					Base: "some/base",
    77  				},
    78  			},
    79  		},
    80  		{
    81  			name: "file",
    82  			src: source.Description{
    83  				ID:       "test-id",
    84  				Name:     "some-name",
    85  				Version:  "some-version",
    86  				Supplier: "optional-supplier",
    87  				Metadata: source.FileMetadata{
    88  					Path:     "some/path",
    89  					Digests:  []file.Digest{{Algorithm: "sha256", Value: "some-digest"}},
    90  					MIMEType: "text/plain",
    91  				},
    92  			},
    93  			expected: model.Source{
    94  				ID:       "test-id",
    95  				Name:     "some-name",
    96  				Version:  "some-version",
    97  				Supplier: "optional-supplier",
    98  				Type:     "file",
    99  				Metadata: source.FileMetadata{
   100  					Path:     "some/path",
   101  					Digests:  []file.Digest{{Algorithm: "sha256", Value: "some-digest"}},
   102  					MIMEType: "text/plain",
   103  				},
   104  			},
   105  		},
   106  		{
   107  			name: "image",
   108  			src: source.Description{
   109  				ID:      "test-id",
   110  				Name:    "some-name",
   111  				Version: "some-version",
   112  				Metadata: source.ImageMetadata{
   113  					UserInput:      "user-input",
   114  					ID:             "id...",
   115  					ManifestDigest: "digest...",
   116  					MediaType:      "type...",
   117  				},
   118  			},
   119  			expected: model.Source{
   120  				ID:      "test-id",
   121  				Name:    "some-name",
   122  				Version: "some-version",
   123  				Type:    "image",
   124  				Metadata: source.ImageMetadata{
   125  					UserInput:      "user-input",
   126  					ID:             "id...",
   127  					ManifestDigest: "digest...",
   128  					MediaType:      "type...",
   129  					RepoDigests:    []string{},
   130  					Tags:           []string{},
   131  				},
   132  			},
   133  		},
   134  		{
   135  			name: "snap",
   136  			src: source.Description{
   137  				ID:      "test-id",
   138  				Name:    "some-name",
   139  				Version: "some-version",
   140  				Metadata: source.SnapMetadata{
   141  					Summary:       "some summary",
   142  					Base:          "some/base",
   143  					Grade:         "some grade",
   144  					Confinement:   "some confinement",
   145  					Architectures: []string{"x86_64", "arm64"},
   146  					Digests:       []file.Digest{{Algorithm: "sha256", Value: "some-digest"}},
   147  				},
   148  			},
   149  			expected: model.Source{
   150  				ID:      "test-id",
   151  				Name:    "some-name",
   152  				Version: "some-version",
   153  				Type:    "snap",
   154  				Metadata: source.SnapMetadata{
   155  					Summary:       "some summary",
   156  					Base:          "some/base",
   157  					Grade:         "some grade",
   158  					Confinement:   "some confinement",
   159  					Architectures: []string{"x86_64", "arm64"},
   160  					Digests:       []file.Digest{{Algorithm: "sha256", Value: "some-digest"}},
   161  				},
   162  			},
   163  		},
   164  		// below are regression tests for when the name/version are not provided
   165  		// historically we've hoisted up the name/version from the metadata, now it is a simple pass-through
   166  		{
   167  			name: "directory - no name/version",
   168  			src: source.Description{
   169  				ID: "test-id",
   170  				Metadata: source.DirectoryMetadata{
   171  					Path: "some/path",
   172  					Base: "some/base",
   173  				},
   174  			},
   175  			expected: model.Source{
   176  				ID:   "test-id",
   177  				Type: "directory",
   178  				Metadata: source.DirectoryMetadata{
   179  					Path: "some/path",
   180  					Base: "some/base",
   181  				},
   182  			},
   183  		},
   184  		{
   185  			name: "file - no name/version",
   186  			src: source.Description{
   187  				ID: "test-id",
   188  				Metadata: source.FileMetadata{
   189  					Path:     "some/path",
   190  					Digests:  []file.Digest{{Algorithm: "sha256", Value: "some-digest"}},
   191  					MIMEType: "text/plain",
   192  				},
   193  			},
   194  			expected: model.Source{
   195  				ID:   "test-id",
   196  				Type: "file",
   197  				Metadata: source.FileMetadata{
   198  					Path:     "some/path",
   199  					Digests:  []file.Digest{{Algorithm: "sha256", Value: "some-digest"}},
   200  					MIMEType: "text/plain",
   201  				},
   202  			},
   203  		},
   204  		{
   205  			name: "image - no name/version",
   206  			src: source.Description{
   207  				ID: "test-id",
   208  				Metadata: source.ImageMetadata{
   209  					UserInput:      "user-input",
   210  					ID:             "id...",
   211  					ManifestDigest: "digest...",
   212  					MediaType:      "type...",
   213  				},
   214  			},
   215  			expected: model.Source{
   216  				ID:   "test-id",
   217  				Type: "image",
   218  				Metadata: source.ImageMetadata{
   219  					UserInput:      "user-input",
   220  					ID:             "id...",
   221  					ManifestDigest: "digest...",
   222  					MediaType:      "type...",
   223  					RepoDigests:    []string{},
   224  					Tags:           []string{},
   225  				},
   226  			},
   227  		},
   228  	}
   229  	for _, test := range tests {
   230  		t.Run(test.name, func(t *testing.T) {
   231  			// assert the model transformation is correct
   232  			actual := toSourceModel(test.src)
   233  			assert.Equal(t, test.expected, actual)
   234  
   235  			// track each scheme tested (passed or not)
   236  			tracker.Tested(t, test.expected.Metadata)
   237  		})
   238  	}
   239  }
   240  
   241  func Test_toFileType(t *testing.T) {
   242  
   243  	badType := stereoscopeFile.Type(0x1337)
   244  	var allTypesTested []stereoscopeFile.Type
   245  	tests := []struct {
   246  		ty   stereoscopeFile.Type
   247  		name string
   248  	}{
   249  		{
   250  			ty:   stereoscopeFile.TypeRegular,
   251  			name: "RegularFile",
   252  		},
   253  		{
   254  			ty:   stereoscopeFile.TypeDirectory,
   255  			name: "Directory",
   256  		},
   257  		{
   258  			ty:   stereoscopeFile.TypeSymLink,
   259  			name: "SymbolicLink",
   260  		},
   261  		{
   262  			ty:   stereoscopeFile.TypeHardLink,
   263  			name: "HardLink",
   264  		},
   265  		{
   266  			ty:   stereoscopeFile.TypeSocket,
   267  			name: "Socket",
   268  		},
   269  		{
   270  			ty:   stereoscopeFile.TypeCharacterDevice,
   271  			name: "CharacterDevice",
   272  		},
   273  		{
   274  			ty:   stereoscopeFile.TypeBlockDevice,
   275  			name: "BlockDevice",
   276  		},
   277  		{
   278  			ty:   stereoscopeFile.TypeFIFO,
   279  			name: "FIFONode",
   280  		},
   281  		{
   282  			ty:   stereoscopeFile.TypeIrregular,
   283  			name: "IrregularFile",
   284  		},
   285  		{
   286  			ty:   badType,
   287  			name: "Unknown",
   288  		},
   289  	}
   290  	for _, tt := range tests {
   291  		t.Run(tt.name, func(t *testing.T) {
   292  			assert.Equalf(t, tt.name, toFileType(tt.ty), "toFileType(%v)", tt.ty)
   293  			if tt.ty != badType {
   294  				allTypesTested = append(allTypesTested, tt.ty)
   295  			}
   296  		})
   297  	}
   298  
   299  	assert.ElementsMatch(t, allTypesTested, stereoscopeFile.AllTypes(), "not all file.Types are under test")
   300  }
   301  
   302  func Test_toFileMetadataEntry(t *testing.T) {
   303  	coords := file.Coordinates{
   304  		RealPath:     "/path",
   305  		FileSystemID: "x",
   306  	}
   307  	tests := []struct {
   308  		name     string
   309  		metadata *file.Metadata
   310  		want     *model.FileMetadataEntry
   311  	}{
   312  		{
   313  			name: "no metadata",
   314  		},
   315  		{
   316  			name: "no file info",
   317  			metadata: &file.Metadata{
   318  				FileInfo: nil,
   319  			},
   320  			want: &model.FileMetadataEntry{
   321  				Type: stereoscopeFile.TypeRegular.String(),
   322  			},
   323  		},
   324  		{
   325  			name: "with file info",
   326  			metadata: &file.Metadata{
   327  				FileInfo: &stereoscopeFile.ManualInfo{
   328  					ModeValue: 1,
   329  				},
   330  			},
   331  			want: &model.FileMetadataEntry{
   332  				Mode: 1,
   333  				Type: stereoscopeFile.TypeRegular.String(),
   334  			},
   335  		},
   336  	}
   337  	for _, tt := range tests {
   338  		t.Run(tt.name, func(t *testing.T) {
   339  			assert.Equal(t, tt.want, toFileMetadataEntry(coords, tt.metadata))
   340  		})
   341  	}
   342  }
   343  
   344  func Test_toPackageModel_metadataType(t *testing.T) {
   345  	tests := []struct {
   346  		name string
   347  		p    pkg.Package
   348  		cfg  EncoderConfig
   349  		want model.Package
   350  	}{
   351  		{
   352  			name: "empty config",
   353  			p: pkg.Package{
   354  				Metadata: pkg.RpmDBEntry{},
   355  			},
   356  			cfg: EncoderConfig{},
   357  			want: model.Package{
   358  				PackageCustomData: model.PackageCustomData{
   359  					MetadataType: "rpm-db-entry",
   360  					Metadata:     pkg.RpmDBEntry{},
   361  				},
   362  			},
   363  		},
   364  		{
   365  			name: "legacy config",
   366  			p: pkg.Package{
   367  				Metadata: pkg.RpmDBEntry{},
   368  			},
   369  			cfg: EncoderConfig{
   370  				Legacy: true,
   371  			},
   372  			want: model.Package{
   373  				PackageCustomData: model.PackageCustomData{
   374  					MetadataType: "RpmMetadata",
   375  					Metadata:     pkg.RpmDBEntry{},
   376  				},
   377  			},
   378  		},
   379  	}
   380  	for _, tt := range tests {
   381  		t.Run(tt.name, func(t *testing.T) {
   382  			if d := cmp.Diff(tt.want, toPackageModel(tt.p, file.LocationSorter(nil), tt.cfg), cmpopts.EquateEmpty()); d != "" {
   383  				t.Errorf("unexpected package (-want +got):\n%s", d)
   384  			}
   385  		})
   386  	}
   387  }
   388  
   389  func Test_toPackageModel_layerOrdering(t *testing.T) {
   390  	tests := []struct {
   391  		name       string
   392  		p          pkg.Package
   393  		layerOrder []string
   394  		cfg        EncoderConfig
   395  		want       model.Package
   396  	}{
   397  		{
   398  			name: "with layer ordering",
   399  			p: pkg.Package{
   400  				Name: "pkg-1",
   401  				Licenses: pkg.NewLicenseSet(pkg.License{
   402  					Value: "MIT",
   403  					Locations: file.NewLocationSet(
   404  						file.NewLocationFromCoordinates(file.Coordinates{
   405  							RealPath:     "/lic-a",
   406  							FileSystemID: "fsid-3",
   407  						}),
   408  						file.NewLocationFromCoordinates(file.Coordinates{
   409  							RealPath:     "/lic-a",
   410  							FileSystemID: "fsid-1",
   411  						}),
   412  						file.NewLocationFromCoordinates(file.Coordinates{
   413  							RealPath:     "/lic-b",
   414  							FileSystemID: "fsid-0",
   415  						}),
   416  						file.NewLocationFromCoordinates(file.Coordinates{
   417  							RealPath:     "/lic-a",
   418  							FileSystemID: "fsid-2",
   419  						}),
   420  					),
   421  				}),
   422  				Locations: file.NewLocationSet(
   423  					file.NewLocationFromCoordinates(file.Coordinates{
   424  						RealPath:     "/a",
   425  						FileSystemID: "fsid-3",
   426  					}),
   427  					file.NewLocationFromCoordinates(file.Coordinates{
   428  						RealPath:     "/a",
   429  						FileSystemID: "fsid-1",
   430  					}),
   431  					file.NewLocationFromCoordinates(file.Coordinates{
   432  						RealPath:     "/b",
   433  						FileSystemID: "fsid-0",
   434  					}),
   435  					file.NewLocationFromCoordinates(file.Coordinates{
   436  						RealPath:     "/a",
   437  						FileSystemID: "fsid-2",
   438  					}),
   439  				),
   440  			},
   441  			layerOrder: []string{
   442  				"fsid-0",
   443  				"fsid-1",
   444  				"fsid-2",
   445  				"fsid-3",
   446  			},
   447  			want: model.Package{
   448  				PackageBasicData: model.PackageBasicData{
   449  					Name: "pkg-1",
   450  					Licenses: []model.License{
   451  						{
   452  							Value: "MIT",
   453  							Locations: []file.Location{
   454  								{
   455  									LocationData: file.LocationData{
   456  										Coordinates: file.Coordinates{
   457  											RealPath:     "/lic-b",
   458  											FileSystemID: "fsid-0", // important!
   459  										},
   460  										AccessPath: "/lic-b",
   461  									},
   462  								},
   463  								{
   464  									LocationData: file.LocationData{
   465  										Coordinates: file.Coordinates{
   466  											RealPath:     "/lic-a",
   467  											FileSystemID: "fsid-1", // important!
   468  										},
   469  										AccessPath: "/lic-a",
   470  									},
   471  								},
   472  								{
   473  									LocationData: file.LocationData{
   474  										Coordinates: file.Coordinates{
   475  											RealPath:     "/lic-a",
   476  											FileSystemID: "fsid-2", // important!
   477  										},
   478  										AccessPath: "/lic-a",
   479  									},
   480  								},
   481  								{
   482  									LocationData: file.LocationData{
   483  										Coordinates: file.Coordinates{
   484  											RealPath:     "/lic-a",
   485  											FileSystemID: "fsid-3", // important!
   486  										},
   487  										AccessPath: "/lic-a",
   488  									},
   489  								},
   490  							},
   491  						},
   492  					},
   493  					Locations: []file.Location{
   494  						{
   495  							LocationData: file.LocationData{
   496  								Coordinates: file.Coordinates{
   497  									RealPath:     "/b",
   498  									FileSystemID: "fsid-0", // important!
   499  								},
   500  								AccessPath: "/b",
   501  							},
   502  						},
   503  						{
   504  							LocationData: file.LocationData{
   505  								Coordinates: file.Coordinates{
   506  									RealPath:     "/a",
   507  									FileSystemID: "fsid-1", // important!
   508  								},
   509  								AccessPath: "/a",
   510  							},
   511  						},
   512  						{
   513  							LocationData: file.LocationData{
   514  								Coordinates: file.Coordinates{
   515  									RealPath:     "/a",
   516  									FileSystemID: "fsid-2", // important!
   517  								},
   518  								AccessPath: "/a",
   519  							},
   520  						},
   521  						{
   522  							LocationData: file.LocationData{
   523  								Coordinates: file.Coordinates{
   524  									RealPath:     "/a",
   525  									FileSystemID: "fsid-3", // important!
   526  								},
   527  								AccessPath: "/a",
   528  							},
   529  						},
   530  					},
   531  				},
   532  			},
   533  		},
   534  	}
   535  	for _, tt := range tests {
   536  		t.Run(tt.name, func(t *testing.T) {
   537  			if d := cmp.Diff(tt.want, toPackageModel(tt.p, file.LocationSorter(tt.layerOrder), tt.cfg), cmpopts.EquateEmpty(), cmpopts.IgnoreUnexported(file.LocationData{})); d != "" {
   538  				t.Errorf("unexpected package (-want +got):\n%s", d)
   539  			}
   540  		})
   541  	}
   542  }
   543  
   544  func Test_toLocationModel(t *testing.T) {
   545  	tests := []struct {
   546  		name      string
   547  		locations file.LocationSet
   548  		layers    []string
   549  		want      []file.Location
   550  	}{
   551  		{
   552  			name:      "empty location set",
   553  			locations: file.NewLocationSet(),
   554  			layers:    []string{"fsid-1"},
   555  			want:      []file.Location{},
   556  		},
   557  		{
   558  			name: "nil layer order map",
   559  			locations: file.NewLocationSet(
   560  				file.NewLocationFromCoordinates(file.Coordinates{
   561  					RealPath:     "/a",
   562  					FileSystemID: "fsid-1",
   563  				}),
   564  				file.NewLocationFromCoordinates(file.Coordinates{
   565  					RealPath:     "/b",
   566  					FileSystemID: "fsid-2",
   567  				}),
   568  			),
   569  			layers: nil, // please don't panic!
   570  			want: []file.Location{
   571  				{
   572  					LocationData: file.LocationData{
   573  						Coordinates: file.Coordinates{
   574  							RealPath:     "/a",
   575  							FileSystemID: "fsid-1",
   576  						},
   577  						AccessPath: "/a",
   578  					},
   579  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   580  				},
   581  				{
   582  					LocationData: file.LocationData{
   583  						Coordinates: file.Coordinates{
   584  							RealPath:     "/b",
   585  							FileSystemID: "fsid-2",
   586  						},
   587  						AccessPath: "/b",
   588  					},
   589  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   590  				},
   591  			},
   592  		},
   593  		{
   594  			name: "go case",
   595  			locations: file.NewLocationSet(
   596  				file.NewLocationFromCoordinates(file.Coordinates{
   597  					RealPath:     "/a",
   598  					FileSystemID: "fsid-3",
   599  				}),
   600  				file.NewLocationFromCoordinates(file.Coordinates{
   601  					RealPath:     "/b",
   602  					FileSystemID: "fsid-1",
   603  				}),
   604  				file.NewLocationFromCoordinates(file.Coordinates{
   605  					RealPath:     "/c",
   606  					FileSystemID: "fsid-2",
   607  				}),
   608  			),
   609  			layers: []string{
   610  				"fsid-1",
   611  				"fsid-2",
   612  				"fsid-3",
   613  			},
   614  			want: []file.Location{
   615  				{
   616  					LocationData: file.LocationData{
   617  						Coordinates: file.Coordinates{
   618  							RealPath:     "/b",
   619  							FileSystemID: "fsid-1",
   620  						},
   621  						AccessPath: "/b",
   622  					},
   623  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   624  				},
   625  				{
   626  					LocationData: file.LocationData{
   627  						Coordinates: file.Coordinates{
   628  							RealPath:     "/c",
   629  							FileSystemID: "fsid-2",
   630  						},
   631  						AccessPath: "/c",
   632  					},
   633  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   634  				},
   635  				{
   636  					LocationData: file.LocationData{
   637  						Coordinates: file.Coordinates{
   638  							RealPath:     "/a",
   639  							FileSystemID: "fsid-3",
   640  						},
   641  						AccessPath: "/a",
   642  					},
   643  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   644  				},
   645  			},
   646  		},
   647  		{
   648  			name: "same layer different paths", // prove we can sort by path irrespective of layer
   649  			locations: file.NewLocationSet(
   650  				file.NewLocationFromCoordinates(file.Coordinates{
   651  					RealPath:     "/c",
   652  					FileSystemID: "fsid-1",
   653  				}),
   654  				file.NewLocationFromCoordinates(file.Coordinates{
   655  					RealPath:     "/a",
   656  					FileSystemID: "fsid-1",
   657  				}),
   658  				file.NewLocationFromCoordinates(file.Coordinates{
   659  					RealPath:     "/b",
   660  					FileSystemID: "fsid-1",
   661  				}),
   662  			),
   663  			layers: []string{
   664  				"fsid-1",
   665  			},
   666  			want: []file.Location{
   667  				{
   668  					LocationData: file.LocationData{
   669  						Coordinates: file.Coordinates{
   670  							RealPath:     "/a",
   671  							FileSystemID: "fsid-1",
   672  						},
   673  						AccessPath: "/a",
   674  					},
   675  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   676  				},
   677  				{
   678  					LocationData: file.LocationData{
   679  						Coordinates: file.Coordinates{
   680  							RealPath:     "/b",
   681  							FileSystemID: "fsid-1",
   682  						},
   683  						AccessPath: "/b",
   684  					},
   685  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   686  				},
   687  				{
   688  					LocationData: file.LocationData{
   689  						Coordinates: file.Coordinates{
   690  							RealPath:     "/c",
   691  							FileSystemID: "fsid-1",
   692  						},
   693  						AccessPath: "/c",
   694  					},
   695  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   696  				},
   697  			},
   698  		},
   699  		{
   700  			name: "mixed layers and paths",
   701  			locations: file.NewLocationSet(
   702  				file.NewLocationFromCoordinates(file.Coordinates{
   703  					RealPath:     "/z",
   704  					FileSystemID: "fsid-3",
   705  				}),
   706  				file.NewLocationFromCoordinates(file.Coordinates{
   707  					RealPath:     "/a",
   708  					FileSystemID: "fsid-2",
   709  				}),
   710  				file.NewLocationFromCoordinates(file.Coordinates{
   711  					RealPath:     "/b",
   712  					FileSystemID: "fsid-1",
   713  				}),
   714  				file.NewLocationFromCoordinates(file.Coordinates{
   715  					RealPath:     "/c",
   716  					FileSystemID: "fsid-2",
   717  				}),
   718  			),
   719  			layers: []string{
   720  				"fsid-1",
   721  				"fsid-2",
   722  				"fsid-3",
   723  			},
   724  			want: []file.Location{
   725  				{
   726  					LocationData: file.LocationData{
   727  						Coordinates: file.Coordinates{
   728  							RealPath:     "/b",
   729  							FileSystemID: "fsid-1",
   730  						},
   731  						AccessPath: "/b",
   732  					},
   733  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   734  				},
   735  				{
   736  					LocationData: file.LocationData{
   737  						Coordinates: file.Coordinates{
   738  							RealPath:     "/a",
   739  							FileSystemID: "fsid-2",
   740  						},
   741  						AccessPath: "/a",
   742  					},
   743  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   744  				},
   745  				{
   746  					LocationData: file.LocationData{
   747  						Coordinates: file.Coordinates{
   748  							RealPath:     "/c",
   749  							FileSystemID: "fsid-2",
   750  						},
   751  						AccessPath: "/c",
   752  					},
   753  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   754  				},
   755  				{
   756  					LocationData: file.LocationData{
   757  						Coordinates: file.Coordinates{
   758  							RealPath:     "/z",
   759  							FileSystemID: "fsid-3",
   760  						},
   761  						AccessPath: "/z",
   762  					},
   763  					LocationMetadata: file.LocationMetadata{Annotations: map[string]string{}},
   764  				},
   765  			},
   766  		},
   767  	}
   768  
   769  	for _, tt := range tests {
   770  		t.Run(tt.name, func(t *testing.T) {
   771  			got := toLocationsModel(tt.locations, file.LocationSorter(tt.layers))
   772  			if d := cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(file.LocationData{})); d != "" {
   773  				t.Errorf("toLocationsModel() mismatch (-want +got):\n%s", d)
   774  			}
   775  		})
   776  	}
   777  }
   778  
   779  func Test_sortFiles(t *testing.T) {
   780  	tests := []struct {
   781  		name   string
   782  		files  []model.File
   783  		layers []string
   784  		want   []model.File
   785  	}{
   786  		{
   787  			name:   "empty files slice",
   788  			files:  []model.File{},
   789  			layers: []string{"fsid-1"},
   790  			want:   []model.File{},
   791  		},
   792  		{
   793  			name: "nil layer order map",
   794  			files: []model.File{
   795  				{
   796  					ID: "file-1",
   797  					Location: file.Coordinates{
   798  						RealPath:     "/a",
   799  						FileSystemID: "fsid-1",
   800  					},
   801  				},
   802  				{
   803  					ID: "file-2",
   804  					Location: file.Coordinates{
   805  						RealPath:     "/b",
   806  						FileSystemID: "fsid-2",
   807  					},
   808  				},
   809  			},
   810  			layers: nil,
   811  			want: []model.File{
   812  				{
   813  					ID: "file-1",
   814  					Location: file.Coordinates{
   815  						RealPath:     "/a",
   816  						FileSystemID: "fsid-1",
   817  					},
   818  				},
   819  				{
   820  					ID: "file-2",
   821  					Location: file.Coordinates{
   822  						RealPath:     "/b",
   823  						FileSystemID: "fsid-2",
   824  					},
   825  				},
   826  			},
   827  		},
   828  		{
   829  			name: "layer ordering",
   830  			files: []model.File{
   831  				{
   832  					ID: "file-1",
   833  					Location: file.Coordinates{
   834  						RealPath:     "/a",
   835  						FileSystemID: "fsid-3",
   836  					},
   837  				},
   838  				{
   839  					ID: "file-2",
   840  					Location: file.Coordinates{
   841  						RealPath:     "/b",
   842  						FileSystemID: "fsid-1",
   843  					},
   844  				},
   845  				{
   846  					ID: "file-3",
   847  					Location: file.Coordinates{
   848  						RealPath:     "/c",
   849  						FileSystemID: "fsid-2",
   850  					},
   851  				},
   852  			},
   853  			layers: []string{
   854  				"fsid-1",
   855  				"fsid-2",
   856  				"fsid-3",
   857  			},
   858  			want: []model.File{
   859  				{
   860  					ID: "file-2",
   861  					Location: file.Coordinates{
   862  						RealPath:     "/b",
   863  						FileSystemID: "fsid-1",
   864  					},
   865  				},
   866  				{
   867  					ID: "file-3",
   868  					Location: file.Coordinates{
   869  						RealPath:     "/c",
   870  						FileSystemID: "fsid-2",
   871  					},
   872  				},
   873  				{
   874  					ID: "file-1",
   875  					Location: file.Coordinates{
   876  						RealPath:     "/a",
   877  						FileSystemID: "fsid-3",
   878  					},
   879  				},
   880  			},
   881  		},
   882  		{
   883  			name: "same layer different paths",
   884  			files: []model.File{
   885  				{
   886  					ID: "file-1",
   887  					Location: file.Coordinates{
   888  						RealPath:     "/c",
   889  						FileSystemID: "fsid-1",
   890  					},
   891  				},
   892  				{
   893  					ID: "file-2",
   894  					Location: file.Coordinates{
   895  						RealPath:     "/a",
   896  						FileSystemID: "fsid-1",
   897  					},
   898  				},
   899  				{
   900  					ID: "file-3",
   901  					Location: file.Coordinates{
   902  						RealPath:     "/b",
   903  						FileSystemID: "fsid-1",
   904  					},
   905  				},
   906  			},
   907  			layers: []string{
   908  				"fsid-1",
   909  			},
   910  			want: []model.File{
   911  				{
   912  					ID: "file-2",
   913  					Location: file.Coordinates{
   914  						RealPath:     "/a",
   915  						FileSystemID: "fsid-1",
   916  					},
   917  				},
   918  				{
   919  					ID: "file-3",
   920  					Location: file.Coordinates{
   921  						RealPath:     "/b",
   922  						FileSystemID: "fsid-1",
   923  					},
   924  				},
   925  				{
   926  					ID: "file-1",
   927  					Location: file.Coordinates{
   928  						RealPath:     "/c",
   929  						FileSystemID: "fsid-1",
   930  					},
   931  				},
   932  			},
   933  		},
   934  		{
   935  			name: "stability test - preserve original order for equivalent items",
   936  			files: []model.File{
   937  				{
   938  					ID: "file-1",
   939  					Location: file.Coordinates{
   940  						RealPath:     "/a",
   941  						FileSystemID: "fsid-1",
   942  					},
   943  				},
   944  				{
   945  					ID: "file-2",
   946  					Location: file.Coordinates{
   947  						RealPath:     "/a",
   948  						FileSystemID: "fsid-1",
   949  					},
   950  				},
   951  				{
   952  					ID: "file-3",
   953  					Location: file.Coordinates{
   954  						RealPath:     "/a",
   955  						FileSystemID: "fsid-1",
   956  					},
   957  				},
   958  			},
   959  			layers: []string{
   960  				"fsid-1",
   961  			},
   962  			want: []model.File{
   963  				{
   964  					ID: "file-1",
   965  					Location: file.Coordinates{
   966  						RealPath:     "/a",
   967  						FileSystemID: "fsid-1",
   968  					},
   969  				},
   970  				{
   971  					ID: "file-2",
   972  					Location: file.Coordinates{
   973  						RealPath:     "/a",
   974  						FileSystemID: "fsid-1",
   975  					},
   976  				},
   977  				{
   978  					ID: "file-3",
   979  					Location: file.Coordinates{
   980  						RealPath:     "/a",
   981  						FileSystemID: "fsid-1",
   982  					},
   983  				},
   984  			},
   985  		},
   986  		{
   987  			name: "complex file metadata doesn't affect sorting",
   988  			files: []model.File{
   989  				{
   990  					ID: "file-1",
   991  					Location: file.Coordinates{
   992  						RealPath:     "/a",
   993  						FileSystemID: "fsid-2",
   994  					},
   995  					Metadata: &model.FileMetadataEntry{
   996  						Mode:     0644,
   997  						Type:     "file",
   998  						UserID:   1000,
   999  						GroupID:  1000,
  1000  						MIMEType: "text/plain",
  1001  						Size:     100,
  1002  					},
  1003  					Contents: "content1",
  1004  					Digests: []file.Digest{
  1005  						{
  1006  							Algorithm: "sha256",
  1007  							Value:     "abc123",
  1008  						},
  1009  					},
  1010  				},
  1011  				{
  1012  					ID: "file-2",
  1013  					Location: file.Coordinates{
  1014  						RealPath:     "/b",
  1015  						FileSystemID: "fsid-1",
  1016  					},
  1017  					Metadata: &model.FileMetadataEntry{
  1018  						Mode:     0755,
  1019  						Type:     "directory",
  1020  						UserID:   0,
  1021  						GroupID:  0,
  1022  						MIMEType: "application/directory",
  1023  						Size:     4096,
  1024  					},
  1025  				},
  1026  			},
  1027  			layers: []string{
  1028  				"fsid-1",
  1029  				"fsid-2",
  1030  			},
  1031  			want: []model.File{
  1032  				{
  1033  					ID: "file-2",
  1034  					Location: file.Coordinates{
  1035  						RealPath:     "/b",
  1036  						FileSystemID: "fsid-1",
  1037  					},
  1038  					Metadata: &model.FileMetadataEntry{
  1039  						Mode:     0755,
  1040  						Type:     "directory",
  1041  						UserID:   0,
  1042  						GroupID:  0,
  1043  						MIMEType: "application/directory",
  1044  						Size:     4096,
  1045  					},
  1046  				},
  1047  				{
  1048  					ID: "file-1",
  1049  					Location: file.Coordinates{
  1050  						RealPath:     "/a",
  1051  						FileSystemID: "fsid-2",
  1052  					},
  1053  					Metadata: &model.FileMetadataEntry{
  1054  						Mode:     0644,
  1055  						Type:     "file",
  1056  						UserID:   1000,
  1057  						GroupID:  1000,
  1058  						MIMEType: "text/plain",
  1059  						Size:     100,
  1060  					},
  1061  					Contents: "content1",
  1062  					Digests: []file.Digest{
  1063  						{
  1064  							Algorithm: "sha256",
  1065  							Value:     "abc123",
  1066  						},
  1067  					},
  1068  				},
  1069  			},
  1070  		},
  1071  	}
  1072  
  1073  	for _, tt := range tests {
  1074  		t.Run(tt.name, func(t *testing.T) {
  1075  			files := make([]model.File, len(tt.files))
  1076  			copy(files, tt.files)
  1077  
  1078  			sortFiles(files, file.CoordinatesSorter(tt.layers))
  1079  
  1080  			if d := cmp.Diff(tt.want, files); d != "" {
  1081  				t.Errorf("sortFiles() mismatch (-want +got):\n%s", d)
  1082  			}
  1083  		})
  1084  	}
  1085  }