github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/python/dependency_test.go (about)

     1  package python
     2  
     3  import (
     4  	"context"
     5  	"os"
     6  	"testing"
     7  
     8  	"github.com/google/go-cmp/cmp"
     9  	"github.com/stretchr/testify/assert"
    10  	"github.com/stretchr/testify/require"
    11  
    12  	"github.com/anchore/syft/syft/file"
    13  	"github.com/anchore/syft/syft/pkg"
    14  	"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
    15  )
    16  
    17  func Test_poetryLockDependencySpecifier(t *testing.T) {
    18  
    19  	tests := []struct {
    20  		name string
    21  		p    pkg.Package
    22  		want dependency.Specification
    23  	}{
    24  		{
    25  			name: "no dependencies",
    26  			p: pkg.Package{
    27  				Name: "foo",
    28  				Metadata: pkg.PythonPoetryLockEntry{
    29  					Dependencies: []pkg.PythonPoetryLockDependencyEntry{},
    30  				},
    31  			},
    32  			want: dependency.Specification{
    33  				ProvidesRequires: dependency.ProvidesRequires{
    34  					Provides: []string{"foo"},
    35  				},
    36  			},
    37  		},
    38  		{
    39  			name: "with required dependencies",
    40  			p: pkg.Package{
    41  				Name: "foo",
    42  				Metadata: pkg.PythonPoetryLockEntry{
    43  					Dependencies: []pkg.PythonPoetryLockDependencyEntry{
    44  						{
    45  							Name:    "bar",
    46  							Version: "1.2.3",
    47  						},
    48  					},
    49  				},
    50  			},
    51  			want: dependency.Specification{
    52  				ProvidesRequires: dependency.ProvidesRequires{
    53  					Provides: []string{"foo"},
    54  					Requires: []string{"bar"},
    55  				},
    56  			},
    57  		},
    58  		{
    59  			name: "with optional dependencies (explicit)",
    60  			p: pkg.Package{
    61  				Name: "foo",
    62  				Metadata: pkg.PythonPoetryLockEntry{
    63  					Dependencies: []pkg.PythonPoetryLockDependencyEntry{
    64  						{
    65  							Name:     "bar",
    66  							Version:  "1.2.3",
    67  							Optional: true,
    68  						},
    69  					},
    70  				},
    71  			},
    72  			want: dependency.Specification{
    73  				ProvidesRequires: dependency.ProvidesRequires{
    74  					Provides: []string{"foo"},
    75  					Requires: []string{"bar"},
    76  				},
    77  			},
    78  		},
    79  		{
    80  			name: "without dependencies for non-required extra",
    81  			p: pkg.Package{
    82  				Name: "foo",
    83  				Metadata: pkg.PythonPoetryLockEntry{
    84  					Dependencies: []pkg.PythonPoetryLockDependencyEntry{
    85  						{
    86  							Name:     "bar",
    87  							Version:  "1.2.3",
    88  							Optional: true,
    89  							Markers:  "extra == 'baz'",
    90  						},
    91  					},
    92  					// note: there is no "baz" extra defined
    93  				},
    94  			},
    95  			want: dependency.Specification{
    96  				ProvidesRequires: dependency.ProvidesRequires{
    97  					Provides: []string{"foo"},
    98  					Requires: nil, // no requirements for non-required extra
    99  				},
   100  			},
   101  		},
   102  		{
   103  			name: "package with extra",
   104  			p: pkg.Package{
   105  				Name: "foo",
   106  				Metadata: pkg.PythonPoetryLockEntry{
   107  					Dependencies: []pkg.PythonPoetryLockDependencyEntry{
   108  						{
   109  							Name:     "bar", // note: we NEVER reference this, the extras section is the source of truth here
   110  							Version:  "1.2.3",
   111  							Optional: true,
   112  							Markers:  "extra == 'baz'",
   113  						},
   114  					},
   115  					Extras: []pkg.PythonPoetryLockExtraEntry{
   116  						{
   117  							Name: "baz",
   118  							Dependencies: []string{
   119  								"qux",
   120  							},
   121  						},
   122  					},
   123  				},
   124  			},
   125  			want: dependency.Specification{
   126  				ProvidesRequires: dependency.ProvidesRequires{
   127  					Provides: []string{"foo"},
   128  					Requires: nil, // no requirements for non-required extra
   129  				},
   130  				Variants: []dependency.ProvidesRequires{
   131  					{
   132  						Provides: []string{"foo[baz]"},
   133  						Requires: []string{"qux"},
   134  					},
   135  				},
   136  			},
   137  		},
   138  		{
   139  			name: "package using extra",
   140  			p: pkg.Package{
   141  				Name: "foo",
   142  				Metadata: pkg.PythonPoetryLockEntry{
   143  					Dependencies: []pkg.PythonPoetryLockDependencyEntry{
   144  						{
   145  							Name:    "starlette",
   146  							Version: ">=0.37.2,<0.38.0",
   147  						},
   148  						{
   149  							Name:    "bar",
   150  							Version: "1.2.3",
   151  							Extras:  []string{"standard", "things"}, // note multiple extras needed when installing
   152  						},
   153  					},
   154  					Extras: []pkg.PythonPoetryLockExtraEntry{
   155  						{
   156  							Name: "baz",
   157  							Dependencies: []string{
   158  								"qux (>=2.0.0)", // should strip version constraint
   159  							},
   160  						},
   161  					},
   162  				},
   163  			},
   164  			want: dependency.Specification{
   165  				ProvidesRequires: dependency.ProvidesRequires{
   166  					Provides: []string{"foo"},
   167  					Requires: []string{
   168  						"starlette",
   169  						// note: we break out the package and extra requirements separately
   170  						// and extras are never combined
   171  						"bar",
   172  						"bar[standard]",
   173  						"bar[things]",
   174  					},
   175  				},
   176  				Variants: []dependency.ProvidesRequires{
   177  					{
   178  						Provides: []string{"foo[baz]"},
   179  						Requires: []string{"qux"},
   180  					},
   181  				},
   182  			},
   183  		},
   184  	}
   185  	for _, tt := range tests {
   186  		t.Run(tt.name, func(t *testing.T) {
   187  			assert.Equal(t, tt.want, poetryLockDependencySpecifier(tt.p))
   188  		})
   189  	}
   190  }
   191  
   192  func Test_poetryLockDependencySpecifier_againstPoetryLock(t *testing.T) {
   193  	tests := []struct {
   194  		name    string
   195  		fixture string
   196  		want    []dependency.Specification
   197  	}{
   198  		{
   199  			name:    "simple dependencies with extras",
   200  			fixture: "test-fixtures/poetry/simple-deps/poetry.lock",
   201  			want: []dependency.Specification{
   202  				{
   203  					ProvidesRequires: dependency.ProvidesRequires{
   204  						Provides: []string{"certifi"},
   205  					},
   206  				},
   207  				{
   208  					ProvidesRequires: dependency.ProvidesRequires{
   209  						Provides: []string{"charset-normalizer"},
   210  					},
   211  				},
   212  				{
   213  					ProvidesRequires: dependency.ProvidesRequires{
   214  						Provides: []string{"idna"},
   215  					},
   216  				},
   217  				{
   218  					ProvidesRequires: dependency.ProvidesRequires{
   219  						Provides: []string{"requests"},
   220  						Requires: []string{"certifi", "charset-normalizer", "idna", "urllib3"},
   221  					},
   222  					Variants: []dependency.ProvidesRequires{
   223  						{
   224  							Provides: []string{"requests[socks]"},
   225  							Requires: []string{"pysocks"},
   226  						},
   227  						{
   228  							Provides: []string{"requests[use-chardet-on-py3]"},
   229  							Requires: []string{"chardet"},
   230  						},
   231  					},
   232  				},
   233  				{
   234  					ProvidesRequires: dependency.ProvidesRequires{
   235  						Provides: []string{"urllib3"},
   236  					},
   237  					Variants: []dependency.ProvidesRequires{
   238  						{
   239  							Provides: []string{"urllib3[brotli]"},
   240  							Requires: []string{"brotli", "brotlicffi"},
   241  						},
   242  						{
   243  							Provides: []string{"urllib3[h2]"},
   244  							Requires: []string{"h2"}},
   245  						{
   246  							Provides: []string{"urllib3[socks]"},
   247  							Requires: []string{"pysocks"},
   248  						},
   249  						{
   250  							Provides: []string{"urllib3[zstd]"},
   251  							Requires: []string{"zstandard"},
   252  						},
   253  					},
   254  				},
   255  			},
   256  		},
   257  	}
   258  	for _, tt := range tests {
   259  		t.Run(tt.name, func(t *testing.T) {
   260  			fh, err := os.Open(tt.fixture)
   261  			require.NoError(t, err)
   262  
   263  			plp := newPoetryLockParser(DefaultCatalogerConfig())
   264  			pkgs, err := plp.poetryLockPackages(context.TODO(), file.NewLocationReadCloser(file.NewLocation(tt.fixture), fh))
   265  			require.NoError(t, err)
   266  
   267  			var got []dependency.Specification
   268  			for _, p := range pkgs {
   269  				got = append(got, poetryLockDependencySpecifier(p))
   270  			}
   271  
   272  			if d := cmp.Diff(tt.want, got); d != "" {
   273  				t.Errorf("wrong result (-want +got):\n%s", d)
   274  			}
   275  		})
   276  	}
   277  }
   278  
   279  func Test_extractPackageName(t *testing.T) {
   280  	tests := []struct {
   281  		name  string
   282  		input string
   283  		want  string
   284  	}{
   285  		{
   286  			name:  "simple package name",
   287  			input: "requests",
   288  			want:  "requests",
   289  		},
   290  		{
   291  			name:  "package with version constraint",
   292  			input: "requests >= 2.8.1",
   293  			want:  "requests",
   294  		},
   295  		{
   296  			name:  "package with parentheses version constraint",
   297  			input: "requests (>= 2.8.1)",
   298  			want:  "requests",
   299  		},
   300  		{
   301  			name:  "package with extras",
   302  			input: "requests[security,tests]",
   303  			want:  "requests",
   304  		},
   305  		{
   306  			name:  "package with extras and version",
   307  			input: "requests[security] >= 2.8.1",
   308  			want:  "requests",
   309  		},
   310  		{
   311  			name:  "package with environment marker",
   312  			input: "requests ; python_version < \"2.7\"",
   313  			want:  "requests",
   314  		},
   315  		{
   316  			name:  "package with everything",
   317  			input: "requests[security] >= 2.8.1 ; python_version < \"3\"",
   318  			want:  "requests",
   319  		},
   320  		{
   321  			name:  "package name with capitals (normalization test)",
   322  			input: "Werkzeug (>=0.15)",
   323  			want:  "werkzeug",
   324  		},
   325  		{
   326  			name:  "package name with mixed case",
   327  			input: "Jinja2 (>=2.10.1)",
   328  			want:  "jinja2",
   329  		},
   330  		{
   331  			name:  "package name with underscores",
   332  			input: "some_package >= 1.0",
   333  			want:  "some-package",
   334  		},
   335  		{
   336  			name:  "package name with mixed separators",
   337  			input: "Some_Package.Name >= 1.0",
   338  			want:  "some-package-name",
   339  		},
   340  	}
   341  	for _, tt := range tests {
   342  		t.Run(tt.name, func(t *testing.T) {
   343  			got := extractPackageName(tt.input)
   344  			assert.Equal(t, tt.want, got)
   345  		})
   346  	}
   347  }
   348  
   349  func Test_wheelEggDependencySpecifier(t *testing.T) {
   350  	tests := []struct {
   351  		name string
   352  		p    pkg.Package
   353  		want dependency.Specification
   354  	}{
   355  		{
   356  			name: "no dependencies",
   357  			p: pkg.Package{
   358  				Name: "foo",
   359  				Metadata: pkg.PythonPackage{
   360  					RequiresDist: []string{},
   361  				},
   362  			},
   363  			want: dependency.Specification{
   364  				ProvidesRequires: dependency.ProvidesRequires{
   365  					Provides: []string{"foo"},
   366  				},
   367  			},
   368  		},
   369  		{
   370  			name: "simple dependencies",
   371  			p: pkg.Package{
   372  				Name: "requests",
   373  				Metadata: pkg.PythonPackage{
   374  					RequiresDist: []string{
   375  						"certifi>=2017.4.17",
   376  						"urllib3<1.27,>=1.21.1",
   377  					},
   378  				},
   379  			},
   380  			want: dependency.Specification{
   381  				ProvidesRequires: dependency.ProvidesRequires{
   382  					Provides: []string{"requests"},
   383  					Requires: []string{"certifi", "urllib3"},
   384  				},
   385  			},
   386  		},
   387  		{
   388  			name: "dependencies with capital letters (Flask-like)",
   389  			p: pkg.Package{
   390  				Name: "flask",
   391  				Metadata: pkg.PythonPackage{
   392  					RequiresDist: []string{
   393  						"Werkzeug (>=0.15)",
   394  						"Jinja2 (>=2.10.1)",
   395  						"itsdangerous (>=0.24)",
   396  						"click (>=5.1)",
   397  					},
   398  				},
   399  			},
   400  			want: dependency.Specification{
   401  				ProvidesRequires: dependency.ProvidesRequires{
   402  					Provides: []string{"flask"},
   403  					// Requires are returned in the order they appear in RequiresDist
   404  					Requires: []string{"werkzeug", "jinja2", "itsdangerous", "click"},
   405  				},
   406  			},
   407  		},
   408  		{
   409  			name: "dependencies with extras",
   410  			p: pkg.Package{
   411  				Name: "foo",
   412  				Metadata: pkg.PythonPackage{
   413  					RequiresDist: []string{
   414  						"bar >= 1.0",
   415  						"pytest ; extra == 'dev'",
   416  						"sphinx ; extra == 'docs'",
   417  					},
   418  				},
   419  			},
   420  			want: dependency.Specification{
   421  				ProvidesRequires: dependency.ProvidesRequires{
   422  					Provides: []string{"foo"},
   423  					Requires: []string{"bar", "pytest", "sphinx"},
   424  				},
   425  			},
   426  		},
   427  	}
   428  	for _, tt := range tests {
   429  		t.Run(tt.name, func(t *testing.T) {
   430  			assert.Equal(t, tt.want, wheelEggDependencySpecifier(tt.p))
   431  		})
   432  	}
   433  }
   434  
   435  func Test_pdmLockDependencySpecifier(t *testing.T) {
   436  
   437  	tests := []struct {
   438  		name string
   439  		p    pkg.Package
   440  		want dependency.Specification
   441  	}{
   442  		{
   443  			name: "no dependencies",
   444  			p: pkg.Package{
   445  				Name: "foo",
   446  				Metadata: pkg.PythonPdmLockEntry{
   447  					Dependencies: []string{},
   448  				},
   449  			},
   450  			want: dependency.Specification{
   451  				ProvidesRequires: dependency.ProvidesRequires{
   452  					Provides: []string{"foo"},
   453  				},
   454  			},
   455  		},
   456  		{
   457  			name: "with simple dependencies",
   458  			p: pkg.Package{
   459  				Name: "requests",
   460  				Metadata: pkg.PythonPdmLockEntry{
   461  					Dependencies: []string{
   462  						"certifi>=2017.4.17",
   463  						"urllib3<1.27,>=1.21.1",
   464  					},
   465  				},
   466  			},
   467  			want: dependency.Specification{
   468  				ProvidesRequires: dependency.ProvidesRequires{
   469  					Provides: []string{"requests"},
   470  					Requires: []string{"certifi", "urllib3"},
   471  				},
   472  			},
   473  		},
   474  		{
   475  			name: "with dependencies containing environment markers",
   476  			p: pkg.Package{
   477  				Name: "requests",
   478  				Metadata: pkg.PythonPdmLockEntry{
   479  					Dependencies: []string{
   480  						"certifi>=2017.4.17",
   481  						"chardet<5,>=3.0.2; python_version < \"3\"",
   482  						"charset-normalizer~=2.0.0; python_version >= \"3\"",
   483  						"idna<3,>=2.5; python_version < \"3\"",
   484  					},
   485  				},
   486  			},
   487  			want: dependency.Specification{
   488  				ProvidesRequires: dependency.ProvidesRequires{
   489  					Provides: []string{"requests"},
   490  					Requires: []string{"certifi", "chardet", "charset-normalizer", "idna"},
   491  				},
   492  			},
   493  		},
   494  		{
   495  			name: "with dependencies containing extras",
   496  			p: pkg.Package{
   497  				Name: "pytest-cov",
   498  				Metadata: pkg.PythonPdmLockEntry{
   499  					Dependencies: []string{
   500  						"coverage[toml]>=5.2.1",
   501  						"pytest>=4.6",
   502  					},
   503  				},
   504  			},
   505  			want: dependency.Specification{
   506  				ProvidesRequires: dependency.ProvidesRequires{
   507  					Provides: []string{"pytest-cov"},
   508  					Requires: []string{"coverage", "pytest"},
   509  				},
   510  			},
   511  		},
   512  		{
   513  			name: "package with single extra variant",
   514  			p: pkg.Package{
   515  				Name: "coverage",
   516  				Metadata: pkg.PythonPdmLockEntry{
   517  					Dependencies: []string{}, // base package has no dependencies
   518  					Extras: []pkg.PythonPdmLockExtraVariant{
   519  						{
   520  							Extras: []string{"toml"},
   521  							Dependencies: []string{
   522  								"coverage==7.4.1", // self-reference, should be excluded
   523  								"tomli; python_full_version <= \"3.11.0a6\"",
   524  							},
   525  						},
   526  					},
   527  				},
   528  			},
   529  			want: dependency.Specification{
   530  				ProvidesRequires: dependency.ProvidesRequires{
   531  					Provides: []string{"coverage"},
   532  					Requires: nil,
   533  				},
   534  				Variants: []dependency.ProvidesRequires{
   535  					{
   536  						Provides: []string{"coverage[toml]"},
   537  						Requires: []string{"tomli"}, // coverage self-reference excluded
   538  					},
   539  				},
   540  			},
   541  		},
   542  		{
   543  			name: "package with multiple extras in one variant",
   544  			p: pkg.Package{
   545  				Name: "foo",
   546  				Metadata: pkg.PythonPdmLockEntry{
   547  					Dependencies: []string{"bar>=1.0"},
   548  					Extras: []pkg.PythonPdmLockExtraVariant{
   549  						{
   550  							Extras: []string{"dev", "test"},
   551  							Dependencies: []string{
   552  								"pytest>=6.0",
   553  								"black~=22.0",
   554  								"foo==1.0.0", // self-reference, should be excluded
   555  							},
   556  						},
   557  					},
   558  				},
   559  			},
   560  			want: dependency.Specification{
   561  				ProvidesRequires: dependency.ProvidesRequires{
   562  					Provides: []string{"foo"},
   563  					Requires: []string{"bar"},
   564  				},
   565  				Variants: []dependency.ProvidesRequires{
   566  					{
   567  						Provides: []string{"foo[dev]", "foo[test]"},
   568  						Requires: []string{"pytest", "black"}, // foo self-reference excluded
   569  					},
   570  				},
   571  			},
   572  		},
   573  		{
   574  			name: "package with multiple separate extra variants",
   575  			p: pkg.Package{
   576  				Name: "example",
   577  				Metadata: pkg.PythonPdmLockEntry{
   578  					Dependencies: []string{"requests"},
   579  					Extras: []pkg.PythonPdmLockExtraVariant{
   580  						{
   581  							Extras:       []string{"redis"},
   582  							Dependencies: []string{"redis>=4.0"},
   583  						},
   584  						{
   585  							Extras:       []string{"postgres"},
   586  							Dependencies: []string{"psycopg2>=2.9"},
   587  						},
   588  					},
   589  				},
   590  			},
   591  			want: dependency.Specification{
   592  				ProvidesRequires: dependency.ProvidesRequires{
   593  					Provides: []string{"example"},
   594  					Requires: []string{"requests"},
   595  				},
   596  				Variants: []dependency.ProvidesRequires{
   597  					{
   598  						Provides: []string{"example[redis]"},
   599  						Requires: []string{"redis"},
   600  					},
   601  					{
   602  						Provides: []string{"example[postgres]"},
   603  						Requires: []string{"psycopg2"},
   604  					},
   605  				},
   606  			},
   607  		},
   608  	}
   609  	for _, tt := range tests {
   610  		t.Run(tt.name, func(t *testing.T) {
   611  			assert.Equal(t, tt.want, pdmLockDependencySpecifier(tt.p))
   612  		})
   613  	}
   614  }