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

     1  package python
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path"
     8  	"testing"
     9  
    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/pkgtest"
    15  )
    16  
    17  func Test_PackageCataloger(t *testing.T) {
    18  	ctx := context.TODO()
    19  	tests := []struct {
    20  		name             string
    21  		fixture          string
    22  		expectedPackages []pkg.Package
    23  	}{
    24  		{
    25  			name:    "egg-file-no-version",
    26  			fixture: "test-fixtures/site-packages/no-version",
    27  			expectedPackages: []pkg.Package{
    28  				{
    29  					Name:      "no-version",
    30  					Locations: file.NewLocationSet(file.NewLocation("no-version-py3.8.egg-info")),
    31  					PURL:      "pkg:pypi/no-version",
    32  					Type:      pkg.PythonPkg,
    33  					Language:  pkg.Python,
    34  					FoundBy:   "python-installed-package-cataloger",
    35  					Metadata: pkg.PythonPackage{
    36  						Name:                 "no-version",
    37  						SitePackagesRootPath: ".", // requires scanning the grandparent directory to get a valid path
    38  					},
    39  				},
    40  			},
    41  		},
    42  		{
    43  			name:    "dist-info+egg-info site-packages directory",
    44  			fixture: "test-fixtures/site-packages/nested",
    45  			expectedPackages: []pkg.Package{
    46  				{
    47  					Name:     "pygments",
    48  					Version:  "2.6.1",
    49  					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    50  					Type:     pkg.PythonPkg,
    51  					Language: pkg.Python,
    52  					Locations: file.NewLocationSet(
    53  						file.NewLocation("dist-name/dist-info/METADATA"),
    54  						file.NewLocation("dist-name/dist-info/RECORD"),
    55  						file.NewLocation("dist-name/dist-info/direct_url.json"),
    56  						file.NewLocation("dist-name/dist-info/top_level.txt"),
    57  					),
    58  					Licenses: pkg.NewLicenseSet(
    59  						// here we only used the license that was declared in the METADATA file, we did not go searching for other licenses
    60  						// this is the better source of truth when there is no explicit LicenseFile given
    61  						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("dist-name/dist-info/METADATA")),
    62  					),
    63  					FoundBy: "python-installed-package-cataloger",
    64  					Metadata: pkg.PythonPackage{
    65  						Name:                 "Pygments",
    66  						Version:              "2.6.1",
    67  						Platform:             "any",
    68  						Author:               "Georg Brandl",
    69  						AuthorEmail:          "georg@python.org",
    70  						SitePackagesRootPath: "dist-name",
    71  						Files: []pkg.PythonFileRecord{
    72  							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
    73  							{Path: "Pygments-2.6.1.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
    74  							{Path: "Pygments-2.6.1.dist-info/LICENSE.txt", Digest: &pkg.PythonFileDigest{Algorithm: "sha256", Value: "utiUvpzxqFPVpvuPnWG2_Oku6BGuay2I8-NIrqCvqUY"}, Size: "8449"},
    75  							{Path: "Pygments-2.6.1.dist-info/RECORD"},
    76  							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
    77  							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},
    78  
    79  							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
    80  						},
    81  						TopLevelPackages: []string{"pygments", "something_else"},
    82  						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
    83  						RequiresPython:   ">=3.5",
    84  						RequiresDist:     []string{"soupsieve (>1.2)", "html5lib ; extra == 'html5lib'", "lxml ; extra == 'lxml'"},
    85  						ProvidesExtra:    []string{"html5lib", "lxml"},
    86  					},
    87  				},
    88  				{
    89  					Name:     "requests",
    90  					Version:  "2.22.0",
    91  					PURL:     "pkg:pypi/requests@2.22.0",
    92  					Type:     pkg.PythonPkg,
    93  					Language: pkg.Python,
    94  					Locations: file.NewLocationSet(
    95  						file.NewLocation("egg-name/egg-info/PKG-INFO"),
    96  						file.NewLocation("egg-name/egg-info/RECORD"),
    97  						file.NewLocation("egg-name/egg-info/top_level.txt"),
    98  					),
    99  					Licenses: pkg.NewLicenseSet(
   100  						pkg.NewLicenseFromLocationsWithContext(ctx, "Apache 2.0", file.NewLocation("egg-name/egg-info/PKG-INFO")),
   101  					),
   102  					FoundBy: "python-installed-package-cataloger",
   103  					Metadata: pkg.PythonPackage{
   104  						Name:                 "requests",
   105  						Version:              "2.22.0",
   106  						Platform:             "UNKNOWN",
   107  						Author:               "Kenneth Reitz",
   108  						AuthorEmail:          "me@kennethreitz.org",
   109  						SitePackagesRootPath: "egg-name",
   110  						Files: []pkg.PythonFileRecord{
   111  							{Path: "requests-2.22.0.dist-info/INSTALLER", Digest: &pkg.PythonFileDigest{"sha256", "zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg"}, Size: "4"},
   112  							{Path: "requests/__init__.py", Digest: &pkg.PythonFileDigest{"sha256", "PnKCgjcTq44LaAMzB-7--B2FdewRrE8F_vjZeaG9NhA"}, Size: "3921"},
   113  							{Path: "requests/__pycache__/__version__.cpython-38.pyc"},
   114  							{Path: "requests/__pycache__/utils.cpython-38.pyc"},
   115  							{Path: "requests/__version__.py", Digest: &pkg.PythonFileDigest{"sha256", "Bm-GFstQaFezsFlnmEMrJDe8JNROz9n2XXYtODdvjjc"}, Size: "436"},
   116  							{Path: "requests/utils.py", Digest: &pkg.PythonFileDigest{"sha256", "LtPJ1db6mJff2TJSJWKi7rBpzjPS3mSOrjC9zRhoD3A"}, Size: "30049"},
   117  						},
   118  						TopLevelPackages: []string{"requests"},
   119  						RequiresPython:   ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
   120  						ProvidesExtra:    []string{"security", "socks"},
   121  					},
   122  				},
   123  			},
   124  		},
   125  		{
   126  			name:    "DIST-INFO+EGG-INFO site-packages directory (case insensitive)",
   127  			fixture: "test-fixtures/site-packages/uppercase",
   128  			expectedPackages: []pkg.Package{
   129  				{
   130  					Name:     "pygments",
   131  					Version:  "2.6.1",
   132  					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
   133  					Type:     pkg.PythonPkg,
   134  					Language: pkg.Python,
   135  					Locations: file.NewLocationSet(
   136  						file.NewLocation("dist-name/DIST-INFO/METADATA"),
   137  						file.NewLocation("dist-name/DIST-INFO/RECORD"),
   138  						file.NewLocation("dist-name/DIST-INFO/direct_url.json"),
   139  						file.NewLocation("dist-name/DIST-INFO/top_level.txt"),
   140  					),
   141  					Licenses: pkg.NewLicenseSet(
   142  						// here we only used the license that was declared in the METADATA file, we did not go searching for other licenses
   143  						// this is the better source of truth when there is no explicit LicenseFile given
   144  						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("dist-name/DIST-INFO/METADATA")),
   145  					),
   146  					FoundBy: "python-installed-package-cataloger",
   147  					Metadata: pkg.PythonPackage{
   148  						Name:                 "Pygments",
   149  						Version:              "2.6.1",
   150  						Platform:             "any",
   151  						Author:               "Georg Brandl",
   152  						AuthorEmail:          "georg@python.org",
   153  						SitePackagesRootPath: "dist-name",
   154  						Files: []pkg.PythonFileRecord{
   155  							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
   156  							{Path: "Pygments-2.6.1.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
   157  							{Path: "Pygments-2.6.1.dist-info/RECORD"},
   158  							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
   159  							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},
   160  
   161  							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
   162  						},
   163  						TopLevelPackages: []string{"pygments", "something_else"},
   164  						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
   165  						RequiresPython:   ">=3.5",
   166  					},
   167  				},
   168  				{
   169  					Name:     "requests",
   170  					Version:  "2.22.0",
   171  					PURL:     "pkg:pypi/requests@2.22.0",
   172  					Type:     pkg.PythonPkg,
   173  					Language: pkg.Python,
   174  					Locations: file.NewLocationSet(
   175  						file.NewLocation("egg-name/EGG-INFO/PKG-INFO"),
   176  						file.NewLocation("egg-name/EGG-INFO/RECORD"),
   177  						file.NewLocation("egg-name/EGG-INFO/top_level.txt"),
   178  					),
   179  					Licenses: pkg.NewLicenseSet(
   180  						pkg.NewLicenseFromLocationsWithContext(ctx, "Apache 2.0", file.NewLocation("egg-name/EGG-INFO/PKG-INFO")),
   181  					),
   182  					FoundBy: "python-installed-package-cataloger",
   183  					Metadata: pkg.PythonPackage{
   184  						Name:                 "requests",
   185  						Version:              "2.22.0",
   186  						Platform:             "UNKNOWN",
   187  						Author:               "Kenneth Reitz",
   188  						AuthorEmail:          "me@kennethreitz.org",
   189  						SitePackagesRootPath: "egg-name",
   190  						Files: []pkg.PythonFileRecord{
   191  							{Path: "requests-2.22.0.dist-info/INSTALLER", Digest: &pkg.PythonFileDigest{"sha256", "zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg"}, Size: "4"},
   192  							{Path: "requests/__init__.py", Digest: &pkg.PythonFileDigest{"sha256", "PnKCgjcTq44LaAMzB-7--B2FdewRrE8F_vjZeaG9NhA"}, Size: "3921"},
   193  							{Path: "requests/__pycache__/__version__.cpython-38.pyc"},
   194  							{Path: "requests/__pycache__/utils.cpython-38.pyc"},
   195  							{Path: "requests/__version__.py", Digest: &pkg.PythonFileDigest{"sha256", "Bm-GFstQaFezsFlnmEMrJDe8JNROz9n2XXYtODdvjjc"}, Size: "436"},
   196  							{Path: "requests/utils.py", Digest: &pkg.PythonFileDigest{"sha256", "LtPJ1db6mJff2TJSJWKi7rBpzjPS3mSOrjC9zRhoD3A"}, Size: "30049"},
   197  						},
   198  						TopLevelPackages: []string{"requests"},
   199  						RequiresPython:   ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
   200  						ProvidesExtra:    []string{"security", "socks"},
   201  					},
   202  				},
   203  			},
   204  		},
   205  		{
   206  			name:    "detect licenses",
   207  			fixture: "test-fixtures/site-packages/license",
   208  			expectedPackages: []pkg.Package{
   209  				{
   210  					Name:     "pygments",
   211  					Version:  "2.6.1",
   212  					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
   213  					Type:     pkg.PythonPkg,
   214  					Language: pkg.Python,
   215  					Locations: file.NewLocationSet(
   216  						file.NewLocation("with-license-file-declared.dist-info/METADATA"), // the LicenseFile is declared in the METADATA file
   217  						file.NewLocation("with-license-file-declared.dist-info/RECORD"),
   218  						file.NewLocation("with-license-file-declared.dist-info/top_level.txt"),
   219  						file.NewLocation("with-license-file-declared.dist-info/direct_url.json"),
   220  					),
   221  					Licenses: pkg.NewLicenseSet(
   222  						pkg.License{
   223  							Value:          "BSD-3-Clause",
   224  							SPDXExpression: "BSD-3-Clause",
   225  							Type:           "concluded",
   226  							Contents:       mustContentsFromLocation(t, "test-fixtures/site-packages/license/with-license-file-declared.dist-info/LICENSE.txt", 0, 1475),
   227  							// we read the path from the LicenseFile field in the METADATA file, then read the license file directly
   228  							Locations: file.NewLocationSet(file.NewLocation("with-license-file-declared.dist-info/LICENSE.txt")),
   229  						},
   230  					),
   231  					FoundBy: "python-installed-package-cataloger",
   232  					Metadata: pkg.PythonPackage{
   233  						Name:                 "Pygments",
   234  						Version:              "2.6.1",
   235  						Platform:             "any",
   236  						Author:               "Georg Brandl",
   237  						AuthorEmail:          "georg@python.org",
   238  						SitePackagesRootPath: ".",
   239  						Files: []pkg.PythonFileRecord{
   240  							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
   241  							{Path: "with-license-file-declared.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
   242  							{Path: "with-license-file-declared.dist-info/LICENSE.txt", Digest: &pkg.PythonFileDigest{Algorithm: "sha256", Value: "utiUvpzxqFPVpvuPnWG2_Oku6BGuay2I8-NIrqCvqUY"}, Size: "8449"},
   243  							{Path: "with-license-file-declared.dist-info/RECORD"},
   244  							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
   245  							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},
   246  
   247  							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
   248  						},
   249  						TopLevelPackages: []string{"pygments", "something_else"},
   250  						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
   251  						RequiresPython:   ">=3.5",
   252  						RequiresDist:     []string{"soupsieve (>1.2)", "html5lib ; extra == 'html5lib'", "lxml ; extra == 'lxml'"},
   253  						ProvidesExtra:    []string{"html5lib", "lxml"},
   254  					},
   255  				},
   256  				{
   257  					Name:     "pygments",
   258  					Version:  "2.6.1",
   259  					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
   260  					Type:     pkg.PythonPkg,
   261  					Language: pkg.Python,
   262  					Locations: file.NewLocationSet(
   263  						file.NewLocation("without-license-file-declared.dist-info/METADATA"), // the LicenseFile is declared in the METADATA file
   264  						file.NewLocation("without-license-file-declared.dist-info/RECORD"),
   265  						file.NewLocation("without-license-file-declared.dist-info/top_level.txt"),
   266  						file.NewLocation("without-license-file-declared.dist-info/direct_url.json"),
   267  					),
   268  					Licenses: pkg.NewLicenseSet(
   269  						pkg.License{
   270  							Value:          "BSD-3-Clause",
   271  							SPDXExpression: "BSD-3-Clause",
   272  							Type:           "concluded",
   273  							Contents:       mustContentsFromLocation(t, "test-fixtures/site-packages/license/with-license-file-declared.dist-info/LICENSE.txt", 0, 1475),
   274  							Locations:      file.NewLocationSet(file.NewLocation("without-license-file-declared.dist-info/LICENSE.txt")),
   275  						},
   276  					),
   277  					FoundBy: "python-installed-package-cataloger",
   278  					Metadata: pkg.PythonPackage{
   279  						Name:                 "Pygments",
   280  						Version:              "2.6.1",
   281  						Platform:             "any",
   282  						Author:               "Georg Brandl",
   283  						AuthorEmail:          "georg@python.org",
   284  						SitePackagesRootPath: ".",
   285  						Files: []pkg.PythonFileRecord{
   286  							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
   287  							{Path: "without-license-file-declared.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
   288  							{Path: "without-license-file-declared.dist-info/LICENSE.txt", Digest: &pkg.PythonFileDigest{Algorithm: "sha256", Value: "utiUvpzxqFPVpvuPnWG2_Oku6BGuay2I8-NIrqCvqUY"}, Size: "8449"},
   289  							{Path: "without-license-file-declared.dist-info/RECORD"},
   290  							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
   291  							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},
   292  
   293  							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
   294  						},
   295  						TopLevelPackages: []string{"pygments", "something_else"},
   296  						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
   297  						RequiresPython:   ">=3.5",
   298  						RequiresDist:     []string{"soupsieve (>1.2)", "html5lib ; extra == 'html5lib'", "lxml ; extra == 'lxml'"},
   299  						ProvidesExtra:    []string{"html5lib", "lxml"},
   300  					},
   301  				},
   302  			},
   303  		},
   304  		{
   305  			name:    "malformed-record",
   306  			fixture: "test-fixtures/site-packages/malformed-record",
   307  			expectedPackages: []pkg.Package{
   308  				{
   309  					Name:     "pygments",
   310  					Version:  "2.6.1",
   311  					PURL:     "pkg:pypi/pygments@2.6.1",
   312  					Type:     pkg.PythonPkg,
   313  					Language: pkg.Python,
   314  					Locations: file.NewLocationSet(
   315  						file.NewLocation("dist-info/METADATA"),
   316  						file.NewLocation("dist-info/RECORD"),
   317  					),
   318  					Licenses: pkg.NewLicenseSet(
   319  						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("dist-info/METADATA")),
   320  					),
   321  					FoundBy: "python-installed-package-cataloger",
   322  					Metadata: pkg.PythonPackage{
   323  						Name:                 "Pygments",
   324  						Version:              "2.6.1",
   325  						Platform:             "any",
   326  						Author:               "Georg Brandl",
   327  						AuthorEmail:          "georg@python.org",
   328  						SitePackagesRootPath: ".",
   329  						Files: []pkg.PythonFileRecord{
   330  							{Path: "flask/json/tag.py", Digest: &pkg.PythonFileDigest{"sha256", "9ehzrmt5k7hxf7ZEK0NOs3swvQyU9fWNe-pnYe69N60"}, Size: "8223"},
   331  							{Path: "../../Scripts/flask.exe", Digest: &pkg.PythonFileDigest{"sha256", "mPrbVeZCDX20himZ_bRai1nCs_tgr7jHIOGZlcgn-T4"}, Size: "93063"},
   332  							{Path: "../../Scripts/flask.exe", Size: "89470", Digest: &pkg.PythonFileDigest{"sha256", "jvqh4N3qOqXLlq40i6ZOLCY9tAOwfwdzIpLDYhRjoqQ"}},
   333  							{Path: "Flask-1.0.2.dist-info/INSTALLER", Size: "4", Digest: &pkg.PythonFileDigest{"sha256", "zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg"}},
   334  						},
   335  						RequiresPython: ">=3.5",
   336  					},
   337  				},
   338  			},
   339  		},
   340  		{
   341  			// in cases where the metadata file is available and the record is not we should still record there is a package
   342  			// additionally empty top_level.txt files should not result in an error
   343  			name:    "partial dist-info directory",
   344  			fixture: "test-fixtures/site-packages/partial.dist-info",
   345  			expectedPackages: []pkg.Package{
   346  				{
   347  					Name:     "pygments",
   348  					Version:  "2.6.1",
   349  					PURL:     "pkg:pypi/pygments@2.6.1",
   350  					Type:     pkg.PythonPkg,
   351  					Language: pkg.Python,
   352  					Locations: file.NewLocationSet(
   353  						file.NewLocation("METADATA"),
   354  					),
   355  					Licenses: pkg.NewLicenseSet(
   356  						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("METADATA")),
   357  					),
   358  					FoundBy: "python-installed-package-cataloger",
   359  					Metadata: pkg.PythonPackage{
   360  						Name:                 "Pygments",
   361  						Version:              "2.6.1",
   362  						Platform:             "any",
   363  						Author:               "Georg Brandl",
   364  						AuthorEmail:          "georg@python.org",
   365  						SitePackagesRootPath: ".",
   366  						RequiresPython:       ">=3.5",
   367  					},
   368  				},
   369  			},
   370  		},
   371  		{
   372  			name:    "egg-info regular file",
   373  			fixture: "test-fixtures/site-packages/test",
   374  			expectedPackages: []pkg.Package{
   375  				{
   376  					Name:     "requests",
   377  					Version:  "2.22.0",
   378  					PURL:     "pkg:pypi/requests@2.22.0",
   379  					Type:     pkg.PythonPkg,
   380  					Language: pkg.Python,
   381  					Locations: file.NewLocationSet(
   382  						file.NewLocation("test.egg-info"),
   383  					),
   384  					Licenses: pkg.NewLicenseSet(
   385  						pkg.NewLicenseFromLocationsWithContext(ctx, "Apache 2.0", file.NewLocation("test.egg-info")),
   386  					),
   387  					FoundBy: "python-installed-package-cataloger",
   388  					Metadata: pkg.PythonPackage{
   389  						Name:                 "requests",
   390  						Version:              "2.22.0",
   391  						Platform:             "UNKNOWN",
   392  						Author:               "Kenneth Reitz",
   393  						AuthorEmail:          "me@kennethreitz.org",
   394  						SitePackagesRootPath: ".",
   395  						RequiresPython:       ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
   396  						ProvidesExtra:        []string{"security", "socks"},
   397  					},
   398  				},
   399  			},
   400  		},
   401  	}
   402  
   403  	for _, test := range tests {
   404  		t.Run(test.name, func(t *testing.T) {
   405  			(pkgtest.NewCatalogTester().
   406  				FromDirectory(t, test.fixture).
   407  				Expects(test.expectedPackages, nil).
   408  				TestCataloger(t, NewInstalledPackageCataloger()))
   409  		})
   410  	}
   411  }
   412  
   413  func Test_PackageCataloger_IgnorePackage(t *testing.T) {
   414  	tests := []struct {
   415  		MetadataFixture string
   416  	}{
   417  		{
   418  			MetadataFixture: "test-fixtures/Python-2.7.egg-info",
   419  		},
   420  		{
   421  			MetadataFixture: "test-fixtures/empty-1.0.0-py3.8.egg-info",
   422  		},
   423  	}
   424  
   425  	for _, test := range tests {
   426  		t.Run(test.MetadataFixture, func(t *testing.T) {
   427  			resolver := file.NewMockResolverForPaths(test.MetadataFixture)
   428  
   429  			actual, _, err := NewInstalledPackageCataloger().Catalog(pkgtest.Context(), resolver)
   430  			require.NoError(t, err)
   431  
   432  			if len(actual) != 0 {
   433  				t.Fatalf("Expected 0 packages but found: %d", len(actual))
   434  			}
   435  		})
   436  	}
   437  }
   438  
   439  func Test_IndexCataloger_Globs(t *testing.T) {
   440  	tests := []struct {
   441  		name     string
   442  		fixture  string
   443  		expected []string
   444  	}{
   445  		{
   446  			name:    "obtain index files",
   447  			fixture: "test-fixtures/glob-paths",
   448  			expected: []string{
   449  				"src/requirements.txt",
   450  				"src/extra-requirements.txt",
   451  				"src/requirements-dev.txt",
   452  				"src/1-requirements-dev.txt",
   453  				"src/setup.py",
   454  				"src/poetry.lock",
   455  				"src/Pipfile.lock",
   456  				"src/uv.lock",
   457  				"src/pdm.lock",
   458  			},
   459  		},
   460  	}
   461  
   462  	for _, test := range tests {
   463  		t.Run(test.name, func(t *testing.T) {
   464  			pkgtest.NewCatalogTester().
   465  				FromDirectory(t, test.fixture).
   466  				ExpectsResolverContentQueries(test.expected).
   467  				TestCataloger(t, NewPackageCataloger(DefaultCatalogerConfig()))
   468  		})
   469  	}
   470  }
   471  
   472  func Test_PackageCataloger_Globs(t *testing.T) {
   473  	tests := []struct {
   474  		name     string
   475  		fixture  string
   476  		expected []string
   477  	}{
   478  		{
   479  			name:    "obtain index files",
   480  			fixture: "test-fixtures/glob-paths",
   481  			expected: []string{
   482  				"site-packages/v.DIST-INFO/METADATA",
   483  				"site-packages/w.EGG-INFO/PKG-INFO",
   484  				"site-packages/x.dist-info/METADATA",
   485  				"site-packages/y.egg-info/PKG-INFO",
   486  				"site-packages/z.egg-info",
   487  			},
   488  		},
   489  	}
   490  
   491  	for _, test := range tests {
   492  		t.Run(test.name, func(t *testing.T) {
   493  			pkgtest.NewCatalogTester().
   494  				FromDirectory(t, test.fixture).
   495  				ExpectsResolverContentQueries(test.expected).
   496  				IgnoreUnfulfilledPathResponses("**/pyvenv.cfg").
   497  				TestCataloger(t, NewInstalledPackageCataloger())
   498  		})
   499  	}
   500  }
   501  
   502  func Test_PackageCataloger_Relationships(t *testing.T) {
   503  	tests := []struct {
   504  		name                  string
   505  		fixture               string
   506  		expectedRelationships []string
   507  	}{
   508  		{
   509  			name:                  "poetry - no dependencies",
   510  			fixture:               "test-fixtures/poetry/dev-deps",
   511  			expectedRelationships: nil,
   512  		},
   513  		{
   514  			name:    "poetry - simple dependencies",
   515  			fixture: "test-fixtures/poetry/simple-deps",
   516  			expectedRelationships: []string{
   517  				"certifi @ 2024.2.2 (.) [dependency-of] requests @ 2.32.2 (.)",
   518  				"charset-normalizer @ 3.3.2 (.) [dependency-of] requests @ 2.32.2 (.)",
   519  				"idna @ 3.7 (.) [dependency-of] requests @ 2.32.2 (.)",
   520  				"urllib3 @ 2.2.1 (.) [dependency-of] requests @ 2.32.2 (.)",
   521  			},
   522  		},
   523  		{
   524  			name:    "poetry - multiple extras",
   525  			fixture: "test-fixtures/poetry/multiple-extras",
   526  			expectedRelationships: []string{
   527  				"anyio @ 4.3.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
   528  				"anyio @ 4.3.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   529  				"anyio @ 4.3.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   530  				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   531  				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   532  				"certifi @ 2024.2.2 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   533  				"certifi @ 2024.2.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
   534  				"cffi @ 1.16.0 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
   535  				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   536  				"h2 @ 4.1.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   537  				"h2 @ 4.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   538  				"hpack @ 4.0.0 (.) [dependency-of] h2 @ 4.1.0 (.)",
   539  				"httpcore @ 1.0.5 (.) [dependency-of] httpx @ 0.27.0 (.)",
   540  				"hyperframe @ 6.0.1 (.) [dependency-of] h2 @ 4.1.0 (.)",
   541  				"idna @ 3.7 (.) [dependency-of] anyio @ 4.3.0 (.)",
   542  				"idna @ 3.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
   543  				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.16.0 (.)",
   544  				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.3.0 (.)",
   545  				"sniffio @ 1.3.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
   546  				"socksio @ 1.0.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   547  				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   548  			},
   549  		},
   550  		{
   551  			name:    "poetry - nested extras",
   552  			fixture: "test-fixtures/poetry/nested-extras",
   553  			expectedRelationships: []string{
   554  				"annotated-types @ 0.7.0 (.) [dependency-of] pydantic @ 2.7.1 (.)",
   555  				"anyio @ 4.3.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
   556  				"anyio @ 4.3.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   557  				"anyio @ 4.3.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   558  				"anyio @ 4.3.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
   559  				"anyio @ 4.3.0 (.) [dependency-of] watchfiles @ 0.21.0 (.)",
   560  				"certifi @ 2024.2.2 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   561  				"certifi @ 2024.2.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
   562  				"click @ 8.1.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
   563  				"click @ 8.1.7 (.) [dependency-of] python-dotenv @ 1.0.1 (.)",
   564  				"click @ 8.1.7 (.) [dependency-of] typer @ 0.12.3 (.)",
   565  				"click @ 8.1.7 (.) [dependency-of] uvicorn @ 0.29.0 (.)",
   566  				"colorama @ 0.4.6 (.) [dependency-of] click @ 8.1.7 (.)",
   567  				"colorama @ 0.4.6 (.) [dependency-of] pygments @ 2.18.0 (.)",
   568  				"colorama @ 0.4.6 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
   569  				"dnspython @ 2.6.1 (.) [dependency-of] email-validator @ 2.1.1 (.)",
   570  				"email-validator @ 2.1.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   571  				"email-validator @ 2.1.1 (.) [dependency-of] pydantic @ 2.7.1 (.)",
   572  				"fastapi @ 0.111.0 (.) [dependency-of] fastapi-cli @ 0.0.4 (.)",
   573  				"fastapi-cli @ 0.0.4 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   574  				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   575  				"h11 @ 0.14.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)",
   576  				"httpcore @ 1.0.5 (.) [dependency-of] dnspython @ 2.6.1 (.)",
   577  				"httpcore @ 1.0.5 (.) [dependency-of] httpx @ 0.27.0 (.)",
   578  				"httptools @ 0.6.1 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
   579  				"httpx @ 0.27.0 (.) [dependency-of] dnspython @ 2.6.1 (.)",
   580  				"httpx @ 0.27.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   581  				"httpx @ 0.27.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
   582  				"idna @ 3.7 (.) [dependency-of] anyio @ 4.3.0 (.)",
   583  				"idna @ 3.7 (.) [dependency-of] dnspython @ 2.6.1 (.)",
   584  				"idna @ 3.7 (.) [dependency-of] email-validator @ 2.1.1 (.)",
   585  				"idna @ 3.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
   586  				"itsdangerous @ 2.2.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   587  				"itsdangerous @ 2.2.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
   588  				"jinja2 @ 3.1.4 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   589  				"jinja2 @ 3.1.4 (.) [dependency-of] starlette @ 0.37.2 (.)",
   590  				"markdown-it-py @ 3.0.0 (.) [dependency-of] rich @ 13.7.1 (.)",
   591  				"mdurl @ 0.1.2 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
   592  				"orjson @ 3.10.3 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   593  				"pydantic @ 2.7.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   594  				"pydantic @ 2.7.1 (.) [dependency-of] pydantic-extra-types @ 2.7.0 (.)",
   595  				"pydantic @ 2.7.1 (.) [dependency-of] pydantic-settings @ 2.2.1 (.)",
   596  				"pydantic-core @ 2.18.2 (.) [dependency-of] pydantic @ 2.7.1 (.)",
   597  				"pydantic-extra-types @ 2.7.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   598  				"pydantic-settings @ 2.2.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   599  				"pygments @ 2.18.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   600  				"pygments @ 2.18.0 (.) [dependency-of] rich @ 13.7.1 (.)",
   601  				"python-dotenv @ 1.0.1 (.) [dependency-of] pydantic-settings @ 2.2.1 (.)",
   602  				"python-dotenv @ 1.0.1 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
   603  				"python-multipart @ 0.0.9 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   604  				"python-multipart @ 0.0.9 (.) [dependency-of] starlette @ 0.37.2 (.)",
   605  				"pyyaml @ 6.0.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   606  				"pyyaml @ 6.0.1 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
   607  				"pyyaml @ 6.0.1 (.) [dependency-of] pydantic-settings @ 2.2.1 (.)",
   608  				"pyyaml @ 6.0.1 (.) [dependency-of] python-multipart @ 0.0.9 (.)",
   609  				"pyyaml @ 6.0.1 (.) [dependency-of] starlette @ 0.37.2 (.)",
   610  				"pyyaml @ 6.0.1 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
   611  				"rich @ 13.7.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
   612  				"rich @ 13.7.1 (.) [dependency-of] typer @ 0.12.3 (.)",
   613  				"shellingham @ 1.5.4 (.) [dependency-of] typer @ 0.12.3 (.)",
   614  				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.3.0 (.)",
   615  				"sniffio @ 1.3.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
   616  				"starlette @ 0.37.2 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   617  				"typer @ 0.12.3 (.) [dependency-of] fastapi-cli @ 0.0.4 (.)",
   618  				"typing-extensions @ 4.12.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   619  				"typing-extensions @ 4.12.0 (.) [dependency-of] pydantic @ 2.7.1 (.)",
   620  				"typing-extensions @ 4.12.0 (.) [dependency-of] pydantic-core @ 2.18.2 (.)",
   621  				"typing-extensions @ 4.12.0 (.) [dependency-of] typer @ 0.12.3 (.)",
   622  				"ujson @ 5.10.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   623  				"uvicorn @ 0.29.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
   624  				"uvicorn @ 0.29.0 (.) [dependency-of] fastapi-cli @ 0.0.4 (.)",
   625  				"uvloop @ 0.19.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
   626  				"uvloop @ 0.19.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)",     // proof of uvicorn[standard]
   627  				"watchfiles @ 0.21.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
   628  				"websockets @ 12.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)",   // proof of uvicorn[standard]
   629  			},
   630  		},
   631  		{
   632  			name:    "poetry - conflicting extras",
   633  			fixture: "test-fixtures/poetry/conflicting-with-extras",
   634  			expectedRelationships: []string{
   635  				"anyio @ 4.3.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
   636  				"anyio @ 4.3.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   637  				"anyio @ 4.3.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   638  				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   639  				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   640  				"certifi @ 2024.2.2 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   641  				"certifi @ 2024.2.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
   642  				"cffi @ 1.16.0 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
   643  				"colorama @ 0.4.6 (.) [dependency-of] rich @ 0.3.3 (.)",
   644  				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   645  				"h2 @ 4.1.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   646  				"h2 @ 4.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   647  				"hpack @ 4.0.0 (.) [dependency-of] h2 @ 4.1.0 (.)",
   648  				"httpcore @ 1.0.5 (.) [dependency-of] httpx @ 0.27.0 (.)",
   649  				"hyperframe @ 6.0.1 (.) [dependency-of] h2 @ 4.1.0 (.)",
   650  				"idna @ 3.7 (.) [dependency-of] anyio @ 4.3.0 (.)",
   651  				"idna @ 3.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
   652  				"pprintpp @ 0.4.0 (.) [dependency-of] rich @ 0.3.3 (.)",
   653  				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.16.0 (.)",
   654  				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.3.0 (.)",
   655  				"sniffio @ 1.3.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
   656  				"socksio @ 1.0.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
   657  				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
   658  				"typing-extensions @ 3.10.0.2 (.) [dependency-of] rich @ 0.3.3 (.)",
   659  
   660  				// ideally we should NOT see these dependencies. However, they are technically installed in the environment
   661  				// and an import is present in httpx for each of these, so in theory they are actually dependencies even
   662  				// though our pyproject.toml looks like this:
   663  				//
   664  				//     [tool.poetry.dependencies]
   665  				//     python = "^3.11"
   666  				//     httpx = {extras = ["brotli", "http2", "socks"], version = "^0.27.0"}
   667  				//     pygments = "1.6"
   668  				//     click = "<8"
   669  				//     rich = "<10"
   670  				//
   671  				// note that pygments, click, and rich are installed outside of the allowable ranges for the given
   672  				// httpx package version constraints, per the poetry.lock:
   673  				//
   674  				//     # for package httpx
   675  				//     [package.extras]
   676  				//     cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
   677  				//
   678  				// note: the pyproject.toml and poetry.lock state are consistent with each other (just with
   679  				// "poetry install" and "poetry lock", and nothing was forced!)
   680  				"click @ 7.1.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
   681  				"pygments @ 1.6 (.) [dependency-of] httpx @ 0.27.0 (.)",
   682  				"rich @ 0.3.3 (.) [dependency-of] httpx @ 0.27.0 (.)",
   683  			},
   684  		},
   685  		{
   686  			name:    "uv - simple dependencies",
   687  			fixture: "test-fixtures/uv/simple-deps",
   688  			expectedRelationships: []string{
   689  				"certifi @ 2025.1.31 (.) [dependency-of] requests @ 2.32.3 (.)",
   690  				"charset-normalizer @ 3.4.1 (.) [dependency-of] requests @ 2.32.3 (.)",
   691  				"idna @ 3.10 (.) [dependency-of] requests @ 2.32.3 (.)",
   692  				"requests @ 2.32.3 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   693  				"urllib3 @ 2.3.0 (.) [dependency-of] requests @ 2.32.3 (.)",
   694  			},
   695  		},
   696  		{
   697  			name:    "uv - multiple extras",
   698  			fixture: "test-fixtures/uv/multiple-extras",
   699  			expectedRelationships: []string{
   700  				"anyio @ 4.9.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   701  				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   702  				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   703  				"certifi @ 2025.1.31 (.) [dependency-of] httpcore @ 1.0.7 (.)",
   704  				"certifi @ 2025.1.31 (.) [dependency-of] httpx @ 0.28.1 (.)",
   705  				"cffi @ 1.17.1 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
   706  				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.7 (.)",
   707  				"h2 @ 4.2.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   708  				"hpack @ 4.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
   709  				"httpcore @ 1.0.7 (.) [dependency-of] httpx @ 0.28.1 (.)",
   710  				"httpx @ 0.28.1 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   711  				"hyperframe @ 6.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
   712  				"idna @ 3.10 (.) [dependency-of] anyio @ 4.9.0 (.)",
   713  				"idna @ 3.10 (.) [dependency-of] httpx @ 0.28.1 (.)",
   714  				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.17.1 (.)",
   715  				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.9.0 (.)",
   716  				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   717  				"typing-extensions @ 4.13.0 (.) [dependency-of] anyio @ 4.9.0 (.)",
   718  			},
   719  		},
   720  		{
   721  			name:    "uv - nested extras",
   722  			fixture: "test-fixtures/uv/nested-extras",
   723  			expectedRelationships: []string{
   724  				"annotated-types @ 0.7.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
   725  				"anyio @ 4.9.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   726  				"anyio @ 4.9.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
   727  				"anyio @ 4.9.0 (.) [dependency-of] watchfiles @ 1.0.4 (.)",
   728  				"certifi @ 2025.1.31 (.) [dependency-of] httpcore @ 1.0.7 (.)",
   729  				"certifi @ 2025.1.31 (.) [dependency-of] httpx @ 0.28.1 (.)",
   730  				"click @ 8.1.8 (.) [dependency-of] rich-toolkit @ 0.14.0 (.)",
   731  				"click @ 8.1.8 (.) [dependency-of] typer @ 0.15.2 (.)",
   732  				"click @ 8.1.8 (.) [dependency-of] uvicorn @ 0.34.0 (.)",
   733  				"colorama @ 0.4.6 (.) [dependency-of] click @ 8.1.8 (.)",
   734  				"colorama @ 0.4.6 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
   735  				"dnspython @ 2.7.0 (.) [dependency-of] email-validator @ 2.2.0 (.)",
   736  				"email-validator @ 2.2.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   737  				"fastapi @ 0.111.1 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   738  				"fastapi-cli @ 0.0.7 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   739  				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.7 (.)",
   740  				"h11 @ 0.14.0 (.) [dependency-of] uvicorn @ 0.34.0 (.)",
   741  				"httpcore @ 1.0.7 (.) [dependency-of] httpx @ 0.28.1 (.)",
   742  				"httptools @ 0.6.4 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
   743  				"httpx @ 0.28.1 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   744  				"idna @ 3.10 (.) [dependency-of] anyio @ 4.9.0 (.)",
   745  				"idna @ 3.10 (.) [dependency-of] email-validator @ 2.2.0 (.)",
   746  				"idna @ 3.10 (.) [dependency-of] httpx @ 0.28.1 (.)",
   747  				"itsdangerous @ 2.2.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   748  				"jinja2 @ 3.1.6 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   749  				"markdown-it-py @ 3.0.0 (.) [dependency-of] rich @ 13.9.4 (.)",
   750  				"markupsafe @ 3.0.2 (.) [dependency-of] jinja2 @ 3.1.6 (.)",
   751  				"mdurl @ 0.1.2 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
   752  				"orjson @ 3.10.16 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   753  				"pydantic @ 2.11.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   754  				"pydantic @ 2.11.0 (.) [dependency-of] pydantic-extra-types @ 2.10.3 (.)",
   755  				"pydantic @ 2.11.0 (.) [dependency-of] pydantic-settings @ 2.8.1 (.)",
   756  				"pydantic-core @ 2.33.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
   757  				"pydantic-extra-types @ 2.10.3 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   758  				"pydantic-settings @ 2.8.1 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   759  				"pygments @ 2.19.1 (.) [dependency-of] rich @ 13.9.4 (.)",
   760  				"python-dotenv @ 1.1.0 (.) [dependency-of] pydantic-settings @ 2.8.1 (.)",
   761  				"python-dotenv @ 1.1.0 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
   762  				"python-multipart @ 0.0.20 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   763  				"pyyaml @ 6.0.2 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   764  				"pyyaml @ 6.0.2 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
   765  				"rich @ 13.9.4 (.) [dependency-of] rich-toolkit @ 0.14.0 (.)",
   766  				"rich @ 13.9.4 (.) [dependency-of] typer @ 0.15.2 (.)",
   767  				"rich-toolkit @ 0.14.0 (.) [dependency-of] fastapi-cli @ 0.0.7 (.)",
   768  				"shellingham @ 1.5.4 (.) [dependency-of] typer @ 0.15.2 (.)",
   769  				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.9.0 (.)",
   770  				"starlette @ 0.37.2 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   771  				"typer @ 0.15.2 (.) [dependency-of] fastapi-cli @ 0.0.7 (.)",
   772  				"typing-extensions @ 4.13.0 (.) [dependency-of] anyio @ 4.9.0 (.)",
   773  				"typing-extensions @ 4.13.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   774  				"typing-extensions @ 4.13.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
   775  				"typing-extensions @ 4.13.0 (.) [dependency-of] pydantic-core @ 2.33.0 (.)",
   776  				"typing-extensions @ 4.13.0 (.) [dependency-of] pydantic-extra-types @ 2.10.3 (.)",
   777  				"typing-extensions @ 4.13.0 (.) [dependency-of] rich-toolkit @ 0.14.0 (.)",
   778  				"typing-extensions @ 4.13.0 (.) [dependency-of] typer @ 0.15.2 (.)",
   779  				"typing-extensions @ 4.13.0 (.) [dependency-of] typing-inspection @ 0.4.0 (.)",
   780  				"typing-inspection @ 0.4.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
   781  				"ujson @ 5.10.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   782  				"uvicorn @ 0.34.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
   783  				"uvicorn @ 0.34.0 (.) [dependency-of] fastapi-cli @ 0.0.7 (.)",
   784  				"uvloop @ 0.21.0 (.) [dependency-of] uvicorn @ 0.34.0 (.)",     // proof of uvicorn[standard]
   785  				"watchfiles @ 1.0.4 (.) [dependency-of] uvicorn @ 0.34.0 (.)",  // proof of uvicorn[standard]
   786  				"websockets @ 15.0.1 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
   787  			},
   788  		},
   789  		{
   790  			name:    "uv - conflicting extras",
   791  			fixture: "test-fixtures/uv/conflicting-with-extras",
   792  			expectedRelationships: []string{
   793  				"anyio @ 4.6.2.post1 (.) [dependency-of] httpx @ 0.28.1 (.)",
   794  				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   795  				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   796  				"certifi @ 2025.1.31 (.) [dependency-of] httpcore @ 1.0.7 (.)",
   797  				"certifi @ 2025.1.31 (.) [dependency-of] httpx @ 0.28.1 (.)",
   798  				"cffi @ 1.17.1 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
   799  				"colorama @ 0.4.6 (.) [dependency-of] rich @ 0.3.3 (.)",
   800  				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.7 (.)",
   801  				"h2 @ 4.2.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   802  				"hpack @ 4.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
   803  				"httpcore @ 1.0.7 (.) [dependency-of] httpx @ 0.28.1 (.)",
   804  				"httpx @ 0.28.1 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   805  				"hyperframe @ 6.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
   806  				"idna @ 3.10 (.) [dependency-of] anyio @ 4.6.2.post1 (.)",
   807  				"idna @ 3.10 (.) [dependency-of] httpx @ 0.28.1 (.)",
   808  				"pprintpp @ 0.4.0 (.) [dependency-of] rich @ 0.3.3 (.)",
   809  				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.17.1 (.)",
   810  				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.6.2.post1 (.)",
   811  				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
   812  				"typing-extensions @ 3.10.0.2 (.) [dependency-of] rich @ 0.3.3 (.)",
   813  				// ideally we should NOT see these dependencies. However, they are technically installed in the environment
   814  				// and an import is present in httpx for each of these, so in theory they are actually dependencies even
   815  				// though our pyproject.toml looks like this:
   816  				//
   817  				//     [tool.poetry.dependencies]
   818  				//     python = "^3.11"
   819  				//     httpx = {extras = ["brotli", "http2", "socks"], version = "^0.27.0"}
   820  				//     pygments = "1.6"
   821  				//     click = "<8"
   822  				//     rich = "<10"
   823  				//
   824  				// note that pygments, click, and rich are installed outside of the allowable ranges for the given
   825  				// httpx package version constraints, per the poetry.lock:
   826  				//
   827  				//     # for package httpx
   828  				//     [package.extras]
   829  				//     cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
   830  				//
   831  				// note: the pyproject.toml and uv.lock state are consistent with each other (just with
   832  				// "uv sync" and nothing was forced!)
   833  				"click @ 7.1.2 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   834  				"pygments @ 1.6 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   835  				"rich @ 0.3.3 (.) [dependency-of] testpkg @ 0.1.0 (.)",
   836  			},
   837  		},
   838  	}
   839  
   840  	for _, test := range tests {
   841  		t.Run(test.name, func(t *testing.T) {
   842  			pkgtest.NewCatalogTester().
   843  				FromDirectory(t, test.fixture).
   844  				WithPackageStringer(stringPackage).
   845  				ExpectsRelationshipStrings(test.expectedRelationships).
   846  				TestCataloger(t, NewPackageCataloger(DefaultCatalogerConfig()))
   847  		})
   848  	}
   849  }
   850  
   851  func Test_PackageCataloger_SitePackageRelationships(t *testing.T) {
   852  	tests := []struct {
   853  		name                  string
   854  		fixture               string
   855  		expectedRelationships []string
   856  	}{
   857  		{
   858  			name:    "with multiple python installations and virtual envs",
   859  			fixture: "image-multi-site-package",
   860  			expectedRelationships: []string{
   861  				// purely python 3.9 dist-packages
   862  				//
   863  				// in the container, you can get a sense for dependencies with :
   864  				//   $ python3.9 -m pip list | tail -n +3 | awk '{print $1}' | xargs python3.9 -m pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-'
   865  				//
   866  				// which approximates to (all in system packages):
   867  				//
   868  				// - beautifulsoup4: soupsieve
   869  				// - requests: certifi, chardet, idna, urllib3
   870  				// - blessed: six, wcwidth
   871  				// - virtualenv: distlib, filelock, platformdirs
   872  				"certifi @ 2020.12.5 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
   873  				"certifi @ 2020.12.5 (/usr/local/lib/python3.9/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
   874  				"chardet @ 3.0.4 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
   875  				"distlib @ 0.3.9 (/usr/local/lib/python3.9/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)",
   876  				"filelock @ 3.18.0 (/usr/local/lib/python3.9/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)",
   877  				"idna @ 2.10 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
   878  				"idna @ 2.10 (/usr/local/lib/python3.9/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
   879  				"platformdirs @ 4.3.8 (/usr/local/lib/python3.9/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)",
   880  				"six @ 1.16.0 (/usr/local/lib/python3.9/dist-packages) [dependency-of] blessed @ 1.20.0 (/usr/local/lib/python3.9/dist-packages)",
   881  				"soupsieve @ 2.2.1 (/usr/local/lib/python3.9/dist-packages) [dependency-of] beautifulsoup4 @ 4.9.3 (/usr/local/lib/python3.9/dist-packages)",
   882  				"urllib3 @ 1.26.18 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
   883  				"virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages) [dependency-of] filelock @ 3.18.0 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "testing", but we are installing it
   884  				"wcwidth @ 0.2.13 (/usr/local/lib/python3.9/dist-packages) [dependency-of] blessed @ 1.20.0 (/usr/local/lib/python3.9/dist-packages)",
   885  
   886  				// purely python 3.8 dist-packages
   887  				//
   888  				// in the container, you can get a sense for dependencies with :
   889  				//   $ python3.8 -m pip list | tail -n +3 | awk '{print $1}' | xargs python3.8 -m pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-'
   890  				//
   891  				// which approximates to (all in system packages):
   892  				//
   893  				// - beautifulsoup4: soupsieve
   894  				// - requests: certifi, chardet, idna, urllib3
   895  				// - runs: xmod
   896  				// - virtualenv: distlib, filelock, platformdirs
   897  				"certifi @ 2020.12.5 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
   898  				"certifi @ 2020.12.5 (/usr/local/lib/python3.8/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.8/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
   899  				"chardet @ 3.0.4 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
   900  				"distlib @ 0.3.9 (/usr/local/lib/python3.8/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages)",
   901  				"filelock @ 3.16.1 (/usr/local/lib/python3.8/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages)",
   902  				"idna @ 2.10 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
   903  				"idna @ 2.10 (/usr/local/lib/python3.8/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.8/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
   904  				"platformdirs @ 4.3.6 (/usr/local/lib/python3.8/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages)",
   905  				"soupsieve @ 2.2 (/usr/local/lib/python3.8/dist-packages) [dependency-of] beautifulsoup4 @ 4.9.2 (/usr/local/lib/python3.8/dist-packages)",
   906  				"urllib3 @ 1.26.18 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
   907  				"virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages) [dependency-of] filelock @ 3.16.1 (/usr/local/lib/python3.8/dist-packages)", // available when extra == "testing", but we are installing it
   908  				"xmod @ 1.8.1 (/usr/local/lib/python3.8/dist-packages) [dependency-of] runs @ 1.2.2 (/usr/local/lib/python3.8/dist-packages)",
   909  
   910  				// project 1 virtual env
   911  				//
   912  				// in the container, you can get a sense for dependencies with :
   913  				//   $ source /app/project1/venv/bin/activate
   914  				//   $ pip list | tail -n +3 | awk '{print $1}' | xargs pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-' -e 'Location:' | grep -A 1 -B 1 '\-packages'
   915  				//
   916  				// which approximates to (some in virtual env, some in system packages):
   917  				//
   918  				// - beautifulsoup4: soupsieve
   919  				// - requests [SYSTEM]: certifi [SYSTEM], chardet [SYSTEM], idna [SYSTEM], urllib3 [SYSTEM]
   920  				// - blessed [SYSTEM]: six [SYSTEM], wcwidth [SYSTEM]
   921  				// - virtualenv [SYSTEM]: distlib [SYSTEM], filelock [SYSTEM], platformdirs [SYSTEM]
   922  				// - inquirer: python-editor [SYSTEM], blessed [SYSTEM], readchar
   923  				//
   924  				// Note: we'll only see new relationships, so any relationship where there is at least one new player (in FROM or TO)
   925  				"blessed @ 1.20.0 (/usr/local/lib/python3.9/dist-packages) [dependency-of] inquirer @ 3.0.0 (/app/project1/venv/lib/python3.9/site-packages)",      // note: depends on global site package!
   926  				"python-editor @ 1.0.4 (/usr/local/lib/python3.9/dist-packages) [dependency-of] inquirer @ 3.0.0 (/app/project1/venv/lib/python3.9/site-packages)", // note: depends on global site package!
   927  				"readchar @ 4.2.1 (/app/project1/venv/lib/python3.9/site-packages) [dependency-of] inquirer @ 3.0.0 (/app/project1/venv/lib/python3.9/site-packages)",
   928  				"setuptools @ 44.0.0 (/app/project1/venv/lib/python3.9/site-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "test", but we are installing it
   929  				"soupsieve @ 2.3 (/app/project1/venv/lib/python3.9/site-packages) [dependency-of] beautifulsoup4 @ 4.10.0 (/app/project1/venv/lib/python3.9/site-packages)",
   930  
   931  				// project 2 virtual env
   932  				//
   933  				// in the container, you can get a sense for dependencies with :
   934  				//   $ source /app/project2/venv/bin/activate
   935  				//   $ pip list | tail -n +3 | awk '{print $1}' | xargs pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-' -e 'Location:'
   936  				//
   937  				// which approximates to (all in virtual env):
   938  				//
   939  				// - blessed: six, wcwidth
   940  				// - editor: runs, xmod
   941  				// - runs: xmod
   942  				// - inquirer: editor, blessed, readchar
   943  				"blessed @ 1.20.0 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] inquirer @ 3.2.4 (/app/project2/venv/lib/python3.8/site-packages)",
   944  				"editor @ 1.6.6 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] inquirer @ 3.2.4 (/app/project2/venv/lib/python3.8/site-packages)",
   945  				"readchar @ 4.1.0 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] inquirer @ 3.2.4 (/app/project2/venv/lib/python3.8/site-packages)",
   946  				"runs @ 1.2.2 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] editor @ 1.6.6 (/app/project2/venv/lib/python3.8/site-packages)",
   947  				"six @ 1.16.0 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] blessed @ 1.20.0 (/app/project2/venv/lib/python3.8/site-packages)",
   948  				"wcwidth @ 0.2.13 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] blessed @ 1.20.0 (/app/project2/venv/lib/python3.8/site-packages)",
   949  				"xmod @ 1.8.1 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] editor @ 1.6.6 (/app/project2/venv/lib/python3.8/site-packages)",
   950  				"xmod @ 1.8.1 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] runs @ 1.2.2 (/app/project2/venv/lib/python3.8/site-packages)",
   951  			},
   952  		},
   953  	}
   954  
   955  	for _, test := range tests {
   956  		t.Run(test.name, func(t *testing.T) {
   957  			pkgtest.NewCatalogTester().
   958  				WithImageResolver(t, test.fixture).
   959  				WithPackageStringer(stringPackage).
   960  				ExpectsRelationshipStrings(test.expectedRelationships).
   961  				TestCataloger(t, NewInstalledPackageCataloger())
   962  		})
   963  	}
   964  }
   965  
   966  func stringPackage(p pkg.Package) string {
   967  	locs := p.Locations.ToSlice()
   968  	var loc string
   969  	if len(locs) > 0 {
   970  		// we want the location of the site-packages, not the metadata file
   971  		loc = path.Dir(path.Dir(p.Locations.ToSlice()[0].RealPath))
   972  	}
   973  
   974  	return fmt.Sprintf("%s @ %s (%s)", p.Name, p.Version, loc)
   975  }
   976  
   977  func mustContentsFromLocation(t *testing.T, contentsPath string, offset ...int) string {
   978  	t.Helper() // Marks this function as a test helper for cleaner error reporting
   979  	contents, err := os.ReadFile(contentsPath)
   980  	if err != nil {
   981  		t.Fatalf("failed to read file %s: %v", contentsPath, err)
   982  	}
   983  
   984  	if len(offset) == 0 {
   985  		return string(contents)
   986  	}
   987  
   988  	if len(offset) != 2 {
   989  		t.Fatalf("invalid offset provided, expected two integers: start and end")
   990  	}
   991  	start, end := offset[0], offset[1]
   992  
   993  	if start < 0 || end > len(contents) || start > end {
   994  		t.Fatalf("invalid offset range: start=%d, end=%d, content length=%d", start, end, len(contents))
   995  	}
   996  
   997  	return string(contents[start:end])
   998  }