github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/python/parse_pdm_lock_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/require"
    10  
    11  	"github.com/anchore/syft/syft/artifact"
    12  	"github.com/anchore/syft/syft/file"
    13  	"github.com/anchore/syft/syft/pkg"
    14  	"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
    15  )
    16  
    17  func TestParsePdmLock(t *testing.T) {
    18  
    19  	fixture := "test-fixtures/pdm-lock/pdm.lock"
    20  	locations := file.NewLocationSet(file.NewLocation(fixture))
    21  	expectedPkgs := []pkg.Package{
    22  		{
    23  			Name:      "certifi",
    24  			Version:   "2025.1.31",
    25  			PURL:      "pkg:pypi/certifi@2025.1.31",
    26  			Locations: locations,
    27  			Language:  pkg.Python,
    28  			Type:      pkg.PythonPkg,
    29  			Metadata: pkg.PythonPdmLockEntry{
    30  				Summary: "Python package for providing Mozilla's CA Bundle.",
    31  				Marker:  `python_version >= "3.6"`,
    32  				Files: []pkg.PythonPdmFileEntry{
    33  					{
    34  						URL: "",
    35  						Digest: pkg.PythonFileDigest{
    36  							Algorithm: "sha256",
    37  							Value:     "3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651",
    38  						},
    39  					},
    40  					{
    41  						URL: "",
    42  						Digest: pkg.PythonFileDigest{
    43  							Algorithm: "sha256",
    44  							Value:     "ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe",
    45  						},
    46  					},
    47  				},
    48  				RequiresPython: ">=3.6",
    49  			},
    50  		},
    51  		{
    52  			Name:      "chardet",
    53  			Version:   "3.0.4",
    54  			PURL:      "pkg:pypi/chardet@3.0.4",
    55  			Locations: locations,
    56  			Language:  pkg.Python,
    57  			Type:      pkg.PythonPkg,
    58  			Metadata: pkg.PythonPdmLockEntry{
    59  				Summary: "Universal encoding detector for Python 2 and 3",
    60  				Marker:  `os_name == "nt"`,
    61  				Files: []pkg.PythonPdmFileEntry{
    62  					{
    63  						URL: "",
    64  						Digest: pkg.PythonFileDigest{
    65  							Algorithm: "sha256",
    66  							Value:     "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691",
    67  						},
    68  					},
    69  					{
    70  						URL: "",
    71  						Digest: pkg.PythonFileDigest{
    72  							Algorithm: "sha256",
    73  							Value:     "84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
    74  						},
    75  					},
    76  				},
    77  			},
    78  		},
    79  		{
    80  			Name:      "charset-normalizer",
    81  			Version:   "2.0.12",
    82  			PURL:      "pkg:pypi/charset-normalizer@2.0.12",
    83  			Locations: locations,
    84  			Language:  pkg.Python,
    85  			Type:      pkg.PythonPkg,
    86  			Metadata: pkg.PythonPdmLockEntry{
    87  				Summary: "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet.",
    88  				Marker:  `python_version >= "3.6"`,
    89  				Files: []pkg.PythonPdmFileEntry{
    90  					{
    91  						URL: "",
    92  						Digest: pkg.PythonFileDigest{
    93  							Algorithm: "sha256",
    94  							Value:     "6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df",
    95  						},
    96  					},
    97  					{
    98  						URL: "",
    99  						Digest: pkg.PythonFileDigest{
   100  							Algorithm: "sha256",
   101  							Value:     "2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
   102  						},
   103  					},
   104  				},
   105  				RequiresPython: ">=3.5.0",
   106  			},
   107  		},
   108  		{
   109  			Name:      "colorama",
   110  			Version:   "0.3.9",
   111  			PURL:      "pkg:pypi/colorama@0.3.9",
   112  			Locations: locations,
   113  			Language:  pkg.Python,
   114  			Type:      pkg.PythonPkg,
   115  			Metadata: pkg.PythonPdmLockEntry{
   116  				Summary: "Cross-platform colored terminal text.",
   117  				Marker:  `sys_platform == "win32"`,
   118  				Files: []pkg.PythonPdmFileEntry{
   119  					{
   120  						URL: "",
   121  						Digest: pkg.PythonFileDigest{
   122  							Algorithm: "sha256",
   123  							Value:     "463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
   124  						},
   125  					},
   126  					{
   127  						URL: "",
   128  						Digest: pkg.PythonFileDigest{
   129  							Algorithm: "sha256",
   130  							Value:     "48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1",
   131  						},
   132  					},
   133  				},
   134  			},
   135  		},
   136  		{
   137  			Name:      "idna",
   138  			Version:   "2.7",
   139  			PURL:      "pkg:pypi/idna@2.7",
   140  			Locations: locations,
   141  			Language:  pkg.Python,
   142  			Type:      pkg.PythonPkg,
   143  			Metadata: pkg.PythonPdmLockEntry{
   144  				Summary: "Internationalized Domain Names in Applications (IDNA)",
   145  				Files: []pkg.PythonPdmFileEntry{
   146  					{
   147  						URL: "",
   148  						Digest: pkg.PythonFileDigest{
   149  							Algorithm: "sha256",
   150  							Value:     "156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
   151  						},
   152  					},
   153  					{
   154  						URL: "",
   155  						Digest: pkg.PythonFileDigest{
   156  							Algorithm: "sha256",
   157  							Value:     "684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16",
   158  						},
   159  					},
   160  				},
   161  			},
   162  		},
   163  		{
   164  			Name:      "py",
   165  			Version:   "1.4.34",
   166  			PURL:      "pkg:pypi/py@1.4.34",
   167  			Locations: locations,
   168  			Language:  pkg.Python,
   169  			Type:      pkg.PythonPkg,
   170  			Metadata: pkg.PythonPdmLockEntry{
   171  				Summary: "library with cross-python path, ini-parsing, io, code, log facilities",
   172  				Files: []pkg.PythonPdmFileEntry{
   173  					{
   174  						URL: "",
   175  						Digest: pkg.PythonFileDigest{
   176  							Algorithm: "sha256",
   177  							Value:     "2ccb79b01769d99115aa600d7eed99f524bf752bba8f041dc1c184853514655a",
   178  						},
   179  					},
   180  					{
   181  						URL: "",
   182  						Digest: pkg.PythonFileDigest{
   183  							Algorithm: "sha256",
   184  							Value:     "0f2d585d22050e90c7d293b6451c83db097df77871974d90efd5a30dc12fcde3",
   185  						},
   186  					},
   187  				},
   188  			},
   189  		},
   190  		{
   191  			Name:      "pytest",
   192  			Version:   "3.2.5",
   193  			PURL:      "pkg:pypi/pytest@3.2.5",
   194  			Locations: locations,
   195  			Language:  pkg.Python,
   196  			Type:      pkg.PythonPkg,
   197  			Metadata: pkg.PythonPdmLockEntry{
   198  				Summary: "pytest: simple powerful testing with Python",
   199  				Files: []pkg.PythonPdmFileEntry{
   200  					{
   201  						URL: "",
   202  						Digest: pkg.PythonFileDigest{
   203  							Algorithm: "sha256",
   204  							Value:     "6d5bd4f7113b444c55a3bbb5c738a3dd80d43563d063fc42dcb0aaefbdd78b81",
   205  						},
   206  					},
   207  					{
   208  						URL: "",
   209  						Digest: pkg.PythonFileDigest{
   210  							Algorithm: "sha256",
   211  							Value:     "241d7e7798d79192a123ceaf64c602b4d233eacf6d6e42ae27caa97f498b7dc6",
   212  						},
   213  					},
   214  				},
   215  				Dependencies: []string{
   216  					`argparse; python_version == "2.6"`,
   217  					`colorama; sys_platform == "win32"`,
   218  					`ordereddict; python_version == "2.6"`,
   219  					"py>=1.4.33",
   220  					"setuptools",
   221  				},
   222  			},
   223  		},
   224  		{
   225  			Name:      "requests",
   226  			Version:   "2.27.1",
   227  			PURL:      "pkg:pypi/requests@2.27.1",
   228  			Locations: locations,
   229  			Language:  pkg.Python,
   230  			Type:      pkg.PythonPkg,
   231  			Metadata: pkg.PythonPdmLockEntry{
   232  				Summary: "Python HTTP for Humans.",
   233  				Marker:  `python_version >= "3.6"`,
   234  				Files: []pkg.PythonPdmFileEntry{
   235  					{
   236  						URL: "",
   237  						Digest: pkg.PythonFileDigest{
   238  							Algorithm: "sha256",
   239  							Value:     "f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d",
   240  						},
   241  					},
   242  					{
   243  						URL: "",
   244  						Digest: pkg.PythonFileDigest{
   245  							Algorithm: "sha256",
   246  							Value:     "68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
   247  						},
   248  					},
   249  				},
   250  				RequiresPython: ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*",
   251  				Dependencies: []string{
   252  					"certifi>=2017.4.17",
   253  					`chardet<5,>=3.0.2; python_version < "3"`,
   254  					`charset-normalizer~=2.0.0; python_version >= "3"`,
   255  					`idna<3,>=2.5; python_version < "3"`,
   256  					`idna<4,>=2.5; python_version >= "3"`,
   257  					"urllib3<1.27,>=1.21.1",
   258  				},
   259  			},
   260  		},
   261  		{
   262  			Name:      "setuptools",
   263  			Version:   "39.2.0",
   264  			PURL:      "pkg:pypi/setuptools@39.2.0",
   265  			Locations: locations,
   266  			Language:  pkg.Python,
   267  			Type:      pkg.PythonPkg,
   268  			Metadata: pkg.PythonPdmLockEntry{
   269  				Summary: "Easily download, build, install, upgrade, and uninstall Python packages",
   270  				Files: []pkg.PythonPdmFileEntry{
   271  					{
   272  						URL: "",
   273  						Digest: pkg.PythonFileDigest{
   274  							Algorithm: "sha256",
   275  							Value:     "f7cddbb5f5c640311eb00eab6e849f7701fa70bf6a183fc8a2c33dd1d1672fb2",
   276  						},
   277  					},
   278  					{
   279  						URL: "",
   280  						Digest: pkg.PythonFileDigest{
   281  							Algorithm: "sha256",
   282  							Value:     "8fca9275c89964f13da985c3656cb00ba029d7f3916b37990927ffdf264e7926",
   283  						},
   284  					},
   285  				},
   286  				RequiresPython: ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*",
   287  			},
   288  		},
   289  		{
   290  			Name:      "urllib3",
   291  			Version:   "1.26.20",
   292  			PURL:      "pkg:pypi/urllib3@1.26.20",
   293  			Locations: locations,
   294  			Language:  pkg.Python,
   295  			Type:      pkg.PythonPkg,
   296  			Metadata: pkg.PythonPdmLockEntry{
   297  				Summary: "HTTP library with thread-safe connection pooling, file post, and more.",
   298  				Marker:  `python_version >= "3.6"`,
   299  				Files: []pkg.PythonPdmFileEntry{
   300  					{
   301  						URL: "",
   302  						Digest: pkg.PythonFileDigest{
   303  							Algorithm: "sha256",
   304  							Value:     "0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e",
   305  						},
   306  					},
   307  					{
   308  						URL: "",
   309  						Digest: pkg.PythonFileDigest{
   310  							Algorithm: "sha256",
   311  							Value:     "40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32",
   312  						},
   313  					},
   314  				},
   315  				RequiresPython: "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7",
   316  			},
   317  		},
   318  	}
   319  
   320  	// Create a map for easy lookup of packages by name
   321  	pkgMap := make(map[string]pkg.Package)
   322  	for _, p := range expectedPkgs {
   323  		pkgMap[p.Name] = p
   324  	}
   325  
   326  	expectedRelationships := []artifact.Relationship{
   327  		// pytest dependencies
   328  		{
   329  			From: pkgMap["colorama"],
   330  			To:   pkgMap["pytest"],
   331  			Type: artifact.DependencyOfRelationship,
   332  		},
   333  		{
   334  			From: pkgMap["py"],
   335  			To:   pkgMap["pytest"],
   336  			Type: artifact.DependencyOfRelationship,
   337  		},
   338  		{
   339  			From: pkgMap["setuptools"],
   340  			To:   pkgMap["pytest"],
   341  			Type: artifact.DependencyOfRelationship,
   342  		},
   343  		// requests dependencies
   344  		{
   345  			From: pkgMap["certifi"],
   346  			To:   pkgMap["requests"],
   347  			Type: artifact.DependencyOfRelationship,
   348  		},
   349  		{
   350  			From: pkgMap["chardet"],
   351  			To:   pkgMap["requests"],
   352  			Type: artifact.DependencyOfRelationship,
   353  		},
   354  		{
   355  			From: pkgMap["charset-normalizer"],
   356  			To:   pkgMap["requests"],
   357  			Type: artifact.DependencyOfRelationship,
   358  		},
   359  		{
   360  			From: pkgMap["urllib3"],
   361  			To:   pkgMap["requests"],
   362  			Type: artifact.DependencyOfRelationship,
   363  		},
   364  		{
   365  			From: pkgMap["idna"],
   366  			To:   pkgMap["requests"],
   367  			Type: artifact.DependencyOfRelationship,
   368  		},
   369  	}
   370  
   371  	pdmLockParser := newPdmLockParser(DefaultCatalogerConfig())
   372  	pkgtest.TestFileParser(t, fixture, pdmLockParser.parsePdmLock, expectedPkgs, expectedRelationships)
   373  }
   374  
   375  func TestParsePdmLockWithLicenseEnrichment(t *testing.T) {
   376  	ctx := context.TODO()
   377  	fixture := "test-fixtures/pypi-remote/pdm.lock"
   378  	locations := file.NewLocationSet(file.NewLocation(fixture))
   379  	mux, url, teardown := setupPypiRegistry()
   380  	defer teardown()
   381  	tests := []struct {
   382  		name             string
   383  		fixture          string
   384  		config           CatalogerConfig
   385  		requestHandlers  []handlerPath
   386  		expectedPackages []pkg.Package
   387  	}{
   388  		{
   389  			name:   "search remote licenses returns the expected licenses when search is set to true",
   390  			config: CatalogerConfig{SearchRemoteLicenses: true},
   391  			requestHandlers: []handlerPath{
   392  				{
   393  					path:    "/certifi/2025.10.5/json",
   394  					handler: generateMockPypiRegistryHandler("test-fixtures/pypi-remote/registry_response.json"),
   395  				},
   396  			},
   397  			expectedPackages: []pkg.Package{
   398  				{
   399  					Name:      "certifi",
   400  					Version:   "2025.10.5",
   401  					Locations: locations,
   402  					PURL:      "pkg:pypi/certifi@2025.10.5",
   403  					Licenses:  pkg.NewLicenseSet(pkg.NewLicenseWithContext(ctx, "MPL-2.0")),
   404  					Language:  pkg.Python,
   405  					Type:      pkg.PythonPkg,
   406  					Metadata: pkg.PythonPdmLockEntry{
   407  						Summary: "Python package for providing Mozilla's CA Bundle.",
   408  						Marker:  `python_version >= "3.7"`,
   409  						Files: []pkg.PythonPdmFileEntry{
   410  							{
   411  								URL: "",
   412  								Digest: pkg.PythonFileDigest{
   413  									Algorithm: "sha256",
   414  									Value:     "47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43",
   415  								},
   416  							},
   417  							{
   418  								URL: "",
   419  								Digest: pkg.PythonFileDigest{
   420  									Algorithm: "sha256",
   421  									Value:     "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de",
   422  								},
   423  							},
   424  						},
   425  						RequiresPython: ">=3.7",
   426  					},
   427  				},
   428  			},
   429  		},
   430  	}
   431  	for _, tc := range tests {
   432  		t.Run(tc.name, func(t *testing.T) {
   433  			// set up the mock server
   434  			for _, handler := range tc.requestHandlers {
   435  				mux.HandleFunc(handler.path, handler.handler)
   436  			}
   437  			tc.config.PypiBaseURL = url
   438  			pdmLockParser := newPdmLockParser(tc.config)
   439  			pkgtest.TestFileParser(t, fixture, pdmLockParser.parsePdmLock, tc.expectedPackages, nil)
   440  		})
   441  	}
   442  }
   443  
   444  func TestParsePdmLockWithExtras(t *testing.T) {
   445  	// This test verifies that PDM's multiple package entries for different extras combinations
   446  	// are correctly merged into a single package node in the SBOM.
   447  	//
   448  	// The fixture contains TWO [[package]] entries for "coverage":
   449  	//   1. Base coverage package (no extras)
   450  	//   2. coverage with extras = ["toml"]
   451  	//
   452  	// We should get exactly ONE coverage package in the output, with extras properly tracked.
   453  
   454  	fixture := "test-fixtures/pdm-lock-extras/pdm.lock"
   455  	pdmLockParser := newPdmLockParser(DefaultCatalogerConfig())
   456  
   457  	fh, err := os.Open(fixture)
   458  	require.NoError(t, err)
   459  	defer fh.Close()
   460  
   461  	pkgs, relationships, err := pdmLockParser.parsePdmLock(
   462  		context.TODO(),
   463  		nil,
   464  		nil,
   465  		file.NewLocationReadCloser(file.NewLocation(fixture), fh),
   466  	)
   467  
   468  	require.NoError(t, err)
   469  
   470  	// Verify we have the expected number of packages (NOT duplicated coverage)
   471  	require.Len(t, pkgs, 5, "should have exactly 5 packages: coverage, pytest, pytest-cov, tomli, uvloop")
   472  
   473  	// Find the coverage package and verify it's only present once
   474  	var coveragePkg *pkg.Package
   475  	coverageCount := 0
   476  	for i := range pkgs {
   477  		if pkgs[i].Name == "coverage" {
   478  			coverageCount++
   479  			coveragePkg = &pkgs[i]
   480  		}
   481  	}
   482  
   483  	require.Equal(t, 1, coverageCount, "coverage should appear exactly ONCE in the package list (PDM has it twice in the lock file)")
   484  	require.NotNil(t, coveragePkg, "coverage package should be found")
   485  
   486  	// This test verifies file deduplication behavior!
   487  	// The fixture has identical files in both base and extras=["toml"] entries.
   488  	// After merging, the base should have Files populated, but the extras variant should NOT
   489  	// have Files (they're deduplicated because they're identical to base).
   490  	coverageMeta, ok := coveragePkg.Metadata.(pkg.PythonPdmLockEntry)
   491  	require.True(t, ok, "coverage metadata should be PythonPdmLockEntry")
   492  
   493  	expectedMeta := pkg.PythonPdmLockEntry{
   494  		Summary:        "Code coverage measurement for Python",
   495  		RequiresPython: ">=3.8",
   496  		Files: []pkg.PythonPdmFileEntry{
   497  			{
   498  				URL: "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl",
   499  				Digest: pkg.PythonFileDigest{
   500  					Algorithm: "sha256",
   501  					Value:     "077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7",
   502  				},
   503  			},
   504  			{
   505  				URL: "coverage-7.4.1.tar.gz",
   506  				Digest: pkg.PythonFileDigest{
   507  					Algorithm: "sha256",
   508  					Value:     "1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04",
   509  				},
   510  			},
   511  		},
   512  		Extras: []pkg.PythonPdmLockExtraVariant{
   513  			{
   514  				Extras: []string{"toml"},
   515  				Dependencies: []string{
   516  					"coverage==7.4.1",
   517  					"tomli; python_full_version <= \"3.11.0a6\"",
   518  				},
   519  				// Files is nil/empty here because they're identical to base (deduplicated)
   520  			},
   521  		},
   522  	}
   523  
   524  	if diff := cmp.Diff(expectedMeta, coverageMeta); diff != "" {
   525  		t.Errorf("coverage metadata mismatch (-want +got):\n%s", diff)
   526  	}
   527  
   528  	// Verify relationships were created
   529  	require.NotEmpty(t, relationships, "relationships should be created")
   530  
   531  	// Verify pytest-cov has a relationship to coverage
   532  	// Build a package map for easy lookup
   533  	pkgMap := make(map[string]pkg.Package)
   534  	for _, p := range pkgs {
   535  		pkgMap[p.Name] = p
   536  	}
   537  
   538  	// Verify tomli package has marker preserved
   539  	var tomliPkg *pkg.Package
   540  	for i := range pkgs {
   541  		if pkgs[i].Name == "tomli" {
   542  			tomliPkg = &pkgs[i]
   543  			break
   544  		}
   545  	}
   546  	require.NotNil(t, tomliPkg, "tomli package should be found")
   547  	tomliMeta, ok := tomliPkg.Metadata.(pkg.PythonPdmLockEntry)
   548  	require.True(t, ok, "tomli metadata should be PythonPdmLockEntry")
   549  	require.Equal(t, `python_version < "3.11"`, tomliMeta.Marker, "tomli should have marker preserved")
   550  
   551  	// Verify uvloop package has complex marker preserved (multiple AND conditions, negations, mixed quotes)
   552  	var uvloopPkg *pkg.Package
   553  	for i := range pkgs {
   554  		if pkgs[i].Name == "uvloop" {
   555  			uvloopPkg = &pkgs[i]
   556  			break
   557  		}
   558  	}
   559  	require.NotNil(t, uvloopPkg, "uvloop package should be found")
   560  	uvloopMeta, ok := uvloopPkg.Metadata.(pkg.PythonPdmLockEntry)
   561  	require.True(t, ok, "uvloop metadata should be PythonPdmLockEntry")
   562  	require.Equal(t, `platform_python_implementation != 'PyPy' and sys_platform != 'win32' and python_version >= "3.8"`, uvloopMeta.Marker, "uvloop should have complex marker preserved exactly as-is")
   563  
   564  	var foundPytestCovToCoverage bool
   565  	for _, rel := range relationships {
   566  		toPkg, toOk := rel.To.(pkg.Package)
   567  		fromPkg, fromOk := rel.From.(pkg.Package)
   568  		if toOk && fromOk && toPkg.Name == "pytest-cov" && fromPkg.Name == "coverage" {
   569  			foundPytestCovToCoverage = true
   570  			break
   571  		}
   572  	}
   573  	require.True(t, foundPytestCovToCoverage, "should have a dependency relationship from coverage to pytest-cov")
   574  }
   575  
   576  func TestParsePdmLockWithSeparateFilesFixture(t *testing.T) {
   577  	// verify that PythonPdmLockExtraVariant metadata is properly populated when parsing PDM lock files
   578  	// with extras variants. The separate-files fixture contains rfc3986 with base + extras=["idna2008"] variant.
   579  	//
   580  	// The fixture contains TWO [[package]] entries for "rfc3986":
   581  	//   1. Base rfc3986 package (no extras, no dependencies)
   582  	//   2. rfc3986 with extras = ["idna2008"] and dependencies = ["idna", "rfc3986==1.5.0"]
   583  	//
   584  	// We should get exactly ONE rfc3986 package in the output, with the extras variant properly tracked
   585  	// in the Extras field.
   586  
   587  	fixture := "test-fixtures/pdm-lock-separate-files/pdm.lock"
   588  	pdmLockParser := newPdmLockParser(DefaultCatalogerConfig())
   589  
   590  	fh, err := os.Open(fixture)
   591  	require.NoError(t, err)
   592  	defer fh.Close()
   593  
   594  	pkgs, relationships, err := pdmLockParser.parsePdmLock(
   595  		context.TODO(),
   596  		nil,
   597  		nil,
   598  		file.NewLocationReadCloser(file.NewLocation(fixture), fh),
   599  	)
   600  
   601  	require.NoError(t, err)
   602  
   603  	// Find the rfc3986 package and verify it's only present once
   604  	var rfc3986Pkg *pkg.Package
   605  	rfc3986Count := 0
   606  	for i := range pkgs {
   607  		if pkgs[i].Name == "rfc3986" {
   608  			rfc3986Count++
   609  			rfc3986Pkg = &pkgs[i]
   610  		}
   611  	}
   612  
   613  	require.Equal(t, 1, rfc3986Count)
   614  	require.NotNil(t, rfc3986Pkg)
   615  
   616  	require.Equal(t, "rfc3986", rfc3986Pkg.Name)
   617  	require.Equal(t, "1.5.0", rfc3986Pkg.Version)
   618  
   619  	rfc3986Meta, ok := rfc3986Pkg.Metadata.(pkg.PythonPdmLockEntry)
   620  	require.True(t, ok)
   621  
   622  	expectedMeta := pkg.PythonPdmLockEntry{
   623  		Summary:        "Validating URI References per RFC 3986",
   624  		RequiresPython: "",
   625  		Files:          nil, // base package has no files in fixture
   626  		Extras: []pkg.PythonPdmLockExtraVariant{
   627  			{
   628  				Extras: []string{"idna2008"},
   629  				Dependencies: []string{
   630  					"idna",
   631  					"rfc3986==1.5.0",
   632  				},
   633  				Files: nil, // variant also has no files (fixture has no files for either entry)
   634  			},
   635  		},
   636  	}
   637  
   638  	if diff := cmp.Diff(expectedMeta, rfc3986Meta); diff != "" {
   639  		t.Errorf("rfc3986 metadata mismatch (-want +got):\n%s", diff)
   640  	}
   641  
   642  	require.NotEmpty(t, relationships, "relationships should be created")
   643  }
   644  
   645  func TestMergePdmLockPackagesNoBasePackage(t *testing.T) {
   646  	// test the edge case where only extras variants exist (no base package entry)
   647  	// this can happen if PDM lock file only contains package entries with extras
   648  	packages := []pdmLockPackage{
   649  		{
   650  			Name:           "test-package",
   651  			Version:        "1.0.0",
   652  			RequiresPython: ">=3.8",
   653  			Summary:        "Test package summary",
   654  			Marker:         "extra == 'dev'",
   655  			Dependencies:   []string{"pytest", "test-package==1.0.0"},
   656  			Extras:         []string{"dev"},
   657  			Files: []pdmLockPackageFile{
   658  				{
   659  					File: "test-package-1.0.0.tar.gz",
   660  					Hash: "sha256:abc123",
   661  				},
   662  			},
   663  		},
   664  		{
   665  			Name:           "test-package",
   666  			Version:        "1.0.0",
   667  			RequiresPython: ">=3.8",
   668  			Summary:        "Test package summary",
   669  			Marker:         "extra == 'test'",
   670  			Dependencies:   []string{"coverage", "test-package==1.0.0"},
   671  			Extras:         []string{"test"},
   672  			Files: []pdmLockPackageFile{
   673  				{
   674  					File: "test-package-1.0.0.tar.gz",
   675  					Hash: "sha256:abc123",
   676  				},
   677  			},
   678  		},
   679  	}
   680  
   681  	entry := mergePdmLockPackages(packages)
   682  
   683  	// verify fallback logic: when no base package exists, first package's metadata is used
   684  	require.Equal(t, "Test package summary", entry.Summary)
   685  	require.Equal(t, ">=3.8", entry.RequiresPython)
   686  	require.Equal(t, []string{"pytest", "test-package==1.0.0"}, entry.Dependencies)
   687  	require.Equal(t, "extra == 'dev'", entry.Marker)
   688  
   689  	// verify both extras variants are present
   690  	require.Len(t, entry.Extras, 2)
   691  	require.Equal(t, []string{"dev"}, entry.Extras[0].Extras)
   692  	require.Equal(t, []string{"pytest", "test-package==1.0.0"}, entry.Extras[0].Dependencies)
   693  	require.Equal(t, []string{"test"}, entry.Extras[1].Extras)
   694  	require.Equal(t, []string{"coverage", "test-package==1.0.0"}, entry.Extras[1].Dependencies)
   695  }
   696  
   697  func Test_corruptPdmLock(t *testing.T) {
   698  	psr := newPdmLockParser(DefaultCatalogerConfig())
   699  	pkgtest.NewCatalogTester().
   700  		FromFile(t, "test-fixtures/glob-paths/src/pdm.lock").
   701  		WithError().
   702  		TestParser(t, psr.parsePdmLock)
   703  }