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 }