Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,41 +193,44 @@ 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
grep -q "Library" smoke-package-normal.md

- 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
Expand Down
2 changes: 1 addition & 1 deletion skills/dotnet-inspect/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
---

Expand Down
9 changes: 9 additions & 0 deletions src/DotnetInspector.Packages/HttpRetryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).");
Expand Down
20 changes: 20 additions & 0 deletions src/DotnetInspector.Packages/PackageExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ private static async Task<PackageExtractionOutcome> DownloadAndExtractPackageAsy
return null;
}

/// <summary>
/// Returns true when <paramref name="source"/>'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.
/// </summary>
private static bool IsHttpSource(NuGetSource source) =>
Uri.TryCreate(source.Url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);

/// <summary>
/// Discovers the PackageBaseAddress (flat-container) endpoint from a V3 service index.
/// </summary>
Expand All @@ -271,6 +281,16 @@ private static async Task<PackageExtractionOutcome> DownloadAndExtractPackageAsy
NuGetSource source,
Action<string>? 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))
Expand Down
159 changes: 159 additions & 0 deletions src/DotnetInspector.Services.Tests/LocalFolderSourceTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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`.
/// </summary>
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<string> 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<string>();

await PackageExtractor.GetLatestVersionAsync(
client, "TestPackage", [localSource], log: logs.Add);

Assert.Contains(logs, l => l.Contains("MyLocalFeed", StringComparison.Ordinal)
&& l.Contains("non-HTTP", StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// HTTP handler that throws on any request — proves no network call was attempted.
/// </summary>
private sealed class FailingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
throw new HttpRequestException($"Unexpected network access in test: {request.RequestUri}");
}
}

/// <summary>
/// HTTP handler that returns canned JSON when the request URL contains a registered
/// substring, and 404s otherwise.
/// </summary>
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<HttpResponseMessage> 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));
}
}
}
2 changes: 1 addition & 1 deletion src/dotnet-inspect/dotnet-inspect.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<Authors>Richard Lander</Authors>
<PackAsTool>true</PackAsTool>
<ToolCommandName>dotnet-inspect</ToolCommandName>
<VersionPrefix>0.7.6</VersionPrefix>
<VersionPrefix>0.7.7</VersionPrefix>
<PackageId>dotnet-inspect</PackageId>
<Description>A CLI tool for inspecting .NET assemblies and NuGet packages</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
Loading