diff --git a/src/Microsoft.ComponentDetection.Detectors/uv/UvLock.cs b/src/Microsoft.ComponentDetection.Detectors/uv/UvLock.cs index db7d52024..3e7b0d0b3 100644 --- a/src/Microsoft.ComponentDetection.Detectors/uv/UvLock.cs +++ b/src/Microsoft.ComponentDetection.Detectors/uv/UvLock.cs @@ -89,6 +89,17 @@ internal static List ParsePackagesFromModel(object? model) uvPackage.Source = source; } + if (pkgTable.TryGetValue("sdist", out var sdistObj) && sdistObj is TomlTable sdistTable) + { + uvPackage.DownloadUrl = TryGetUrlFromArtifactTable(sdistTable); + } + + if (uvPackage.DownloadUrl == null && + pkgTable.TryGetValue("wheels", out var wheelsObj) && wheelsObj is TomlArray wheelsArray) + { + uvPackage.DownloadUrl = TryGetFirstWheelUrl(wheelsArray); + } + return uvPackage; } @@ -143,4 +154,26 @@ internal static void ParseMetadata(TomlTable? metadataTable, UvPackage uvPackage } } } + + private static string? TryGetUrlFromArtifactTable(TomlTable artifactTable) + { + return artifactTable.TryGetValue("url", out var urlObj) && urlObj is string url ? url : null; + } + + private static string? TryGetFirstWheelUrl(TomlArray wheelsArray) + { + foreach (var wheelObj in wheelsArray) + { + if (wheelObj is TomlTable wheelTable) + { + var url = TryGetUrlFromArtifactTable(wheelTable); + if (!string.IsNullOrWhiteSpace(url)) + { + return url; + } + } + } + + return null; + } } diff --git a/src/Microsoft.ComponentDetection.Detectors/uv/UvPackage.cs b/src/Microsoft.ComponentDetection.Detectors/uv/UvPackage.cs index d9d7443c4..98ac2cedf 100644 --- a/src/Microsoft.ComponentDetection.Detectors/uv/UvPackage.cs +++ b/src/Microsoft.ComponentDetection.Detectors/uv/UvPackage.cs @@ -21,6 +21,9 @@ internal class UvPackage // Source property for uv.lock public UvSource? Source { get; set; } + // Preferred artifact URL (sdist first, then wheel fallback) for provenance. + public string? DownloadUrl { get; set; } + public TypedComponent ToTypedComponent() { if (this.Source?.Git != null) @@ -29,7 +32,13 @@ public TypedComponent ToTypedComponent() return new GitComponent(repoUrl, commitHash); } - return new PipComponent(this.Name, this.Version); + var component = new PipComponent(this.Name, this.Version); + if (Uri.TryCreate(this.DownloadUrl, UriKind.Absolute, out var downloadUri)) + { + component.DownloadUrl = downloadUri; + } + + return component; } private static (Uri RepositoryUrl, string CommitHash) ParseGitUrl(string gitUrl) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockDetectorTests.cs index c0fc82578..233eaca44 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockDetectorTests.cs @@ -611,4 +611,37 @@ public async Task TestUvLockDetector_RecursiveDependency_DoesNotHangOrThrowAsync graph.GetDependenciesForComponent(aId).Should().BeEquivalentTo([bId]); graph.GetDependenciesForComponent(bId).Should().BeEquivalentTo([aId]); } + + [TestMethod] + public async Task TestUvLockDetector_PipPackage_UsesSdistDownloadUrlAsync() + { + var uvLock = @"[[package]] +name = 'myproject' +version = '0.1.0' +source = { virtual = '.' } +dependencies = [ + { name = 'requests' }, +] +[package.metadata] +requires-dist = [ + { name = 'requests' }, +] +[[package]] +name = 'requests' +version = '2.32.0' +source = { registry = 'https://pypi.org/simple' } +sdist = { url = 'https://files.pythonhosted.org/packages/source/r/requests/requests-2.32.0.tar.gz' } +"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile("uv.lock", uvLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponent = componentRecorder.GetDetectedComponents().Single().Component; + detectedComponent.Should().BeOfType(); + + var pipComponent = (PipComponent)detectedComponent; + pipComponent.DownloadUrl.Should().Be(new Uri("https://files.pythonhosted.org/packages/source/r/requests/requests-2.32.0.tar.gz")); + } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockTests.cs index 589fa8234..7fa574750 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/UvLockTests.cs @@ -392,4 +392,39 @@ public void ParsePackage_ParsesSource_Missing() var pkg = uvLock.Packages.First(); pkg.Source.Should().BeNull(); } + + [TestMethod] + public void ParsePackage_ParsesDownloadUrl_FromSdist() + { + var toml = """ +[[package]] +name = 'foo' +version = '1.0.0' +sdist = { url = 'https://files.example.com/foo-1.0.0.tar.gz', hash = 'sha256:abc' } +"""; + + using var ms = new MemoryStream(Encoding.UTF8.GetBytes(toml)); + var uvLock = UvLock.Parse(ms); + uvLock.Packages.Should().ContainSingle(); + uvLock.Packages.First().DownloadUrl.Should().Be("https://files.example.com/foo-1.0.0.tar.gz"); + } + + [TestMethod] + public void ParsePackage_ParsesDownloadUrl_FromWheels_WhenSdistMissing() + { + var toml = """ +[[package]] +name = 'foo' +version = '1.0.0' +wheels = [ + { hash = 'sha256:missing-url' }, + { url = 'https://files.example.com/foo-1.0.0-py3-none-any.whl', hash = 'sha256:def' }, +] +"""; + + using var ms = new MemoryStream(Encoding.UTF8.GetBytes(toml)); + var uvLock = UvLock.Parse(ms); + uvLock.Packages.Should().ContainSingle(); + uvLock.Packages.First().DownloadUrl.Should().Be("https://files.example.com/foo-1.0.0-py3-none-any.whl"); + } }