diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f18c91f..a14bda3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,14 +193,17 @@ jobs: - name: Smoke test - package quiet run: | - dotnet-inspect package System.Text.Json 10.0.0 -v:q > smoke-package.md + # Pipe through cat to work around a .NET 11 preview SDK regression + # where redirecting an AOT binary's stdout directly to a file + # ('> file') truncates the output to ~256 bytes. Piping is fine. + dotnet-inspect package System.Text.Json 10.0.0 -v:q | cat > smoke-package.md cat smoke-package.md grep -q "System.Text.Json" smoke-package.md grep -q "Library" smoke-package.md - name: Smoke test - package normal run: | - dotnet-inspect package System.Text.Json 10.0.0 -v:n > smoke-package-normal.md + dotnet-inspect package System.Text.Json 10.0.0 -v:n | cat > smoke-package-normal.md cat smoke-package-normal.md grep -q "## Package" smoke-package-normal.md grep -q "## Package Dependencies" smoke-package-normal.md @@ -208,26 +211,26 @@ jobs: - name: Smoke test - type command (oneline default) run: | - dotnet-inspect type --package System.Text.Json@10.0.0 -t 5 > smoke-type.txt + dotnet-inspect type --package System.Text.Json@10.0.0 -t 5 | cat > smoke-type.txt cat smoke-type.txt grep -q "JsonSerializer" smoke-type.txt - name: Smoke test - member command (oneline default) run: | - dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -m 5 > smoke-member.txt + dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -m 5 | cat > smoke-member.txt cat smoke-member.txt grep -q "Serialize" smoke-member.txt grep -q "Deserialize" smoke-member.txt - name: Smoke test - type command (markdown) run: | - dotnet-inspect type --package System.Text.Json@10.0.0 -v:m -t 5 > smoke-type.md + dotnet-inspect type --package System.Text.Json@10.0.0 -v:m -t 5 | cat > smoke-type.md cat smoke-type.md grep -q "JsonSerializer" smoke-type.md - name: Smoke test - member command (markdown) run: | - dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -v:m -m 5 > smoke-member.md + dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -v:m -m 5 | cat > smoke-member.md cat smoke-member.md grep -q "JsonSerializer" smoke-member.md grep -q "Serialize" smoke-member.md diff --git a/skills/dotnet-inspect/SKILL.md b/skills/dotnet-inspect/SKILL.md index 5e2ff9c..536fb03 100644 --- a/skills/dotnet-inspect/SKILL.md +++ b/skills/dotnet-inspect/SKILL.md @@ -1,6 +1,6 @@ --- name: dotnet-inspect -version: 0.7.6 +version: 0.7.7 description: Query .NET APIs across NuGet packages, platform libraries, and local files. Search for types, list API surfaces, compare and diff versions, find extension methods and implementors. Use whenever you need to answer questions about .NET library contents. --- diff --git a/src/DotnetInspector.Packages/HttpRetryHelper.cs b/src/DotnetInspector.Packages/HttpRetryHelper.cs index e81412e..6f324a0 100644 --- a/src/DotnetInspector.Packages/HttpRetryHelper.cs +++ b/src/DotnetInspector.Packages/HttpRetryHelper.cs @@ -137,6 +137,15 @@ public static TimeSpan GetRetryDelay(int retryAttempt) log?.Invoke($"Socket error {socketError} (retryable): {url}"); } + catch (NotSupportedException ex) + { + // Thrown by HttpRequestMessage when the URL scheme is unsupported + // (e.g. file:// or a raw local folder path). Treat as non-retryable + // so a local folder NuGet source listed in NuGet.Config can't crash + // remote queries. Issue #310. + log?.Invoke($"HTTP {methodName} unsupported URL (not retryable): {ex.Message}"); + return null; + } catch (DotnetInspector.Core.OfflineException) { log?.Invoke($"Network access is disabled (--offline mode)."); diff --git a/src/DotnetInspector.Packages/PackageExtractor.cs b/src/DotnetInspector.Packages/PackageExtractor.cs index 074d993..cc52f9f 100644 --- a/src/DotnetInspector.Packages/PackageExtractor.cs +++ b/src/DotnetInspector.Packages/PackageExtractor.cs @@ -263,6 +263,16 @@ private static async Task DownloadAndExtractPackageAsy return null; } + /// + /// Returns true when 's URL is an absolute http/https URL. + /// Local folder sources (e.g. `D:\packages`, `/var/packages`, `file://...`) and + /// otherwise unparseable URLs return false — they cannot be queried by the + /// remote-only operations in this class. + /// + private static bool IsHttpSource(NuGetSource source) => + Uri.TryCreate(source.Url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + /// /// Discovers the PackageBaseAddress (flat-container) endpoint from a V3 service index. /// @@ -271,6 +281,16 @@ private static async Task DownloadAndExtractPackageAsy NuGetSource source, Action? log) { + // Skip non-HTTP sources (e.g. local folder feeds from NuGet.Config). + // Passing a file: URL or raw filesystem path to HttpClient throws + // NotSupportedException ("net_http_unsupported_requesturi_scheme, file"), + // which would crash version resolution / package download. Issue #310. + if (!IsHttpSource(source)) + { + log?.Invoke($"Skipping non-HTTP NuGet source '{source.Name}': {source.Url}"); + return null; + } + // The source URL should be the V3 index.json var indexUrl = source.Url; if (!indexUrl.EndsWith("index.json", StringComparison.OrdinalIgnoreCase)) diff --git a/src/DotnetInspector.Services.Tests/LocalFolderSourceTests.cs b/src/DotnetInspector.Services.Tests/LocalFolderSourceTests.cs new file mode 100644 index 0000000..7ff1682 --- /dev/null +++ b/src/DotnetInspector.Services.Tests/LocalFolderSourceTests.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text; +using DotnetInspector.Core; +using DotnetInspector.Packages; +using NuGetSource = NuGetFetch.PackageSource; + +namespace DotnetInspector.Services.Tests; + +/// +/// Regression tests for issue #310: a local folder source from NuGet.Config +/// must not crash version resolution / package download with +/// `System.NotSupportedException: net_http_unsupported_requesturi_scheme, file`. +/// +public class LocalFolderSourceTests : IDisposable +{ + private const string VersionCacheCategory = "versions"; + + public LocalFolderSourceTests() + { + CoreCache.Initialize("dotnet-inspect-test"); + CoreCache.Clear(VersionCacheCategory); + } + + public void Dispose() + { + CoreCache.Clear(VersionCacheCategory); + } + + public static TheoryData LocalFolderUrls() => new() + { + @"D:\some\local\packages", + "/var/local/packages", + "file:///var/packages", + @"C:\Program Files (x86)\Microsoft SDKs\NuGetPackages\", + // Also unparseable / non-absolute should be skipped, not crash: + "local-packages", + }; + + [Theory] + [MemberData(nameof(LocalFolderUrls))] + public async Task GetLatestVersion_LocalFolderSource_DoesNotThrowAndReturnsNull(string folderUrl) + { + // FailingClient throws on any HTTP request — proves we never tried to + // hand the local folder URL to HttpClient. + using var client = new HttpClient(new FailingHandler()); + var localSource = new NuGetSource("local", folderUrl); + + var result = await PackageExtractor.GetLatestVersionAsync( + client, "TestPackage", [localSource], log: null); + + Assert.Null(result); + } + + [Theory] + [MemberData(nameof(LocalFolderUrls))] + public async Task GetVersions_LocalFolderSource_DoesNotThrowAndReturnsNull(string folderUrl) + { + using var client = new HttpClient(new FailingHandler()); + var sourceOptions = new NuGetSourceOptions { Sources = [folderUrl] }; + + var result = await PackageExtractor.GetVersionsAsync( + client, "TestPackage", includePrerelease: false, limit: null, log: null, + sourceOptions: sourceOptions); + + Assert.Null(result); + } + + [Theory] + [MemberData(nameof(LocalFolderUrls))] + public async Task GetPackageDownloadUrl_LocalFolderSource_DoesNotThrowAndReturnsNull(string folderUrl) + { + using var client = new HttpClient(new FailingHandler()); + var localSource = new NuGetSource("local", folderUrl); + + var url = await PackageExtractor.GetPackageDownloadUrlAsync( + client, localSource, "testpackage", "1.0.0", log: null); + + Assert.Null(url); + } + + [Fact] + public async Task GetLatestVersion_FallsBackPastLocalFolderSourceToNuGetOrg() + { + // The exact reproducer from issue #310: a local folder source listed + // before a working HTTP feed must not prevent the HTTP feed from + // resolving the package. + var handler = new StubHandler(); + handler.Add( + "azuresearch-usnc.nuget.org/query", + """{"data":[{"id":"TestPackage","version":"4.2.1"}]}"""); + + using var client = new HttpClient(handler); + + var localSource = new NuGetSource("local", @"D:\some\local\packages"); + var nugetOrg = NuGetSource.NuGetOrg; + + var version = await PackageExtractor.GetLatestVersionAsync( + client, "TestPackage", [localSource, nugetOrg], log: null, skipCache: true); + + Assert.Equal("4.2.1", version); + } + + [Fact] + public async Task GetLatestVersion_LogsSkippedNonHttpSource() + { + using var client = new HttpClient(new FailingHandler()); + var localSource = new NuGetSource("MyLocalFeed", @"D:\some\local\packages"); + var logs = new List(); + + await PackageExtractor.GetLatestVersionAsync( + client, "TestPackage", [localSource], log: logs.Add); + + Assert.Contains(logs, l => l.Contains("MyLocalFeed", StringComparison.Ordinal) + && l.Contains("non-HTTP", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// HTTP handler that throws on any request — proves no network call was attempted. + /// + private sealed class FailingHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new HttpRequestException($"Unexpected network access in test: {request.RequestUri}"); + } + } + + /// + /// HTTP handler that returns canned JSON when the request URL contains a registered + /// substring, and 404s otherwise. + /// + private sealed class StubHandler : HttpMessageHandler + { + private readonly List<(string match, string body)> _routes = []; + + public void Add(string urlSubstring, string body) => _routes.Add((urlSubstring, body)); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri?.ToString() ?? ""; + foreach (var (match, body) in _routes) + { + if (url.Contains(match, StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } +} diff --git a/src/dotnet-inspect/dotnet-inspect.csproj b/src/dotnet-inspect/dotnet-inspect.csproj index 48aeafb..a5ffdf5 100644 --- a/src/dotnet-inspect/dotnet-inspect.csproj +++ b/src/dotnet-inspect/dotnet-inspect.csproj @@ -21,7 +21,7 @@ Richard Lander true dotnet-inspect - 0.7.6 + 0.7.7 dotnet-inspect A CLI tool for inspecting .NET assemblies and NuGet packages MIT