Skip to content

Commit 24a76d0

Browse files
authored
Use the NuGet configuration's feeds for .NET Tool, and detect updates on Artifactory (#5671)
1 parent 550fcab commit 24a76d0

22 files changed

Lines changed: 2265 additions & 165 deletions

File tree

‎AGENTS.md‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,27 @@ place under the same version keeps showing the icon it had until a restart.
7676
|---|---|
7777
| `SearchQueryService` (3.5.0 → 3.0.0-beta → unversioned) | package search |
7878
| `PackageBaseAddress/3.0.0` | version enumeration, update detection, `.nupkg` and embedded-icon URLs |
79-
| `RegistrationsBaseUrl` (3.6.0 → 3.4.0 → 3.0.0-rc → 3.0.0-beta → unversioned) | package metadata, listed/unlisted checks |
79+
| `RegistrationsBaseUrl` (3.6.0 → 3.4.0 → 3.0.0-rc → 3.0.0-beta → unversioned) | package metadata, listed/unlisted checks; versions, `.nupkg` URLs and metadata when there is no `PackageBaseAddress` |
8080

8181
A feed needs `PackageBaseAddress` or `RegistrationsBaseUrl` to be usable. If it advertises no
8282
`SearchQueryService` — or its search returns nothing — search degrades to an exact
8383
package-id lookup against the flat container. Flat-container version lists include unlisted
8484
versions, so an update candidate is confirmed against its registration leaf before being
85-
surfaced.
85+
surfaced. A version index answering 404 means the package is not on that feed, not a failed
86+
check, since .NET Tool asks every configured feed about every installed tool.
87+
88+
A registration-only feed (JFrog Artifactory serves no flat container) is read through its
89+
registration index instead: versions come from its pages, fetching only the pages above the
90+
installed version during an update check; the `.nupkg` URL comes from the registration leaf;
91+
and metadata comes from the version's page entry when the leaf carries no `catalogEntry`.
92+
None of this runs for a feed that has a flat container.
93+
94+
On V2, Artifactory answers `GetUpdates()` with nothing unless `includeAllVersions` is true, and
95+
then truncates the list at 100 without a next link. So when every `GetUpdates()` batch of a
96+
source comes back empty, one `includeAllVersions=true` request tells "nothing is newer" (still
97+
empty: stop there) from "this server ignores the request", in which case each installed package
98+
is looked up with `FindPackagesById()?$filter=IsLatestVersion` (`IsAbsoluteLatestVersion` with
99+
pre-releases). V2 version lists follow `next` links, up to 50 pages.
86100

87101
### Fallback behaviour
88102

@@ -92,9 +106,24 @@ retried against a fabricated V2 endpoint. V2-only feeds never enter the V3 path
92106

93107
### Which feed is which
94108

95-
- **.NET Tool** — `https://api.nuget.org/v3/index.json`. Safe to repoint because the manager
96-
does not support custom sources and never passes the source URL to the `dotnet` CLI. Also
97-
filters search on `packageType=DotnetTool`.
109+
- **.NET Tool** — the enabled sources of the user's NuGet configuration, read (not edited) by
110+
`DotNetSourceHelper` from `dotnet nuget list source --format Detailed` run in the user profile
111+
with `DOTNET_CLI_UI_LANGUAGE=en`, so `disabledPackageSources` and the whole config hierarchy
112+
apply as they do for `dotnet tool install`. Output it cannot read, or a failed command, falls
113+
back to nuget.org. The files from `dotnet nuget config paths` supply `packageSourceMapping`
114+
(an installed package is never sent to a feed the mapping does not allow for its id) and basic
115+
`packageSourceCredentials` (clear text, or DPAPI-encrypted with the `NuGet` entropy on
116+
Windows; a closer file's entry wins even when it cannot be used, such as one restricted to
117+
`negotiate`). `NuGetFeedCredentials` keys each credential by its feed, since feeds sharing a
118+
server (Azure Artifacts) carry different ones, and sends it only on requests made for that
119+
feed and only to that feed's own server; every request site passes the feed it belongs to.
120+
The snapshot is kept until a config file's write time changes, or for ten minutes. Updates are
121+
checked on every enabled feed and the newest wins; the feed at nuget.org's URL is always
122+
`DefaultSource`, whatever its key. `dotnet tool list` names no feed, so installed tools sit
123+
under nuget.org when it is enabled, else the first feed, and `InstalledPackagesKeepTheirSource`
124+
is false so loaders match them to updates and search results from any feed by id. Search
125+
filters on `packageType=DotnetTool` on V3 feeds and local folders; V2 feeds have no such
126+
filter and may list libraries. Icons and installer downloads do not carry credentials.
98127
- **PowerShell / PowerShell 7** — sources are enumerated from `Get-PSRepository` (or
99128
`Get-PSResourceRepository` on PowerShell 7), so the URL is CLI-owned identity
100129
(`PowerShellSourceHelper` compares it literally to choose `Register-PSRepository -Default`)

‎src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public DotNet()
3636
{
3737
Capabilities = new ManagerCapabilities
3838
{
39+
InstalledPackagesKeepTheirSource = false,
3940
CanRunAsAdmin = true,
4041
CanDownloadInstaller = true,
4142
SupportsReleaseNotes = true,
@@ -49,6 +50,13 @@ public DotNet()
4950
Architecture.arm32,
5051
],
5152
SupportsPreRelease = true,
53+
SupportsCustomSources = true,
54+
Sources = new SourceCapabilities
55+
{
56+
KnowsPackageCount = false,
57+
KnowsUpdateDate = false,
58+
CanBeEdited = false,
59+
},
5260
CanListDependencies = true,
5361
SupportsCustomLocations = true,
5462
SupportsCustomPackageIcons = true,
@@ -88,15 +96,34 @@ public DotNet()
8896
],
8997
};
9098

99+
SourcesHelper = new DotNetSourceHelper(this);
91100
DetailsHelper = new DotNetDetailsHelper(this);
92101
OperationHelper = new DotNetPkgOperationHelper(this);
93102
}
94103

95-
protected override string? V3PackageType => "DotnetTool";
104+
protected override string? SearchPackageType => "DotnetTool";
105+
106+
protected override IReadOnlyList<IManagerSource> GetUpdateSources(
107+
IManagerSource installedSource
108+
) => SourcesHelper.GetSources();
109+
110+
protected override bool CanResolveFromSource(string packageId, IManagerSource source) =>
111+
SourcesHelper is not DotNetSourceHelper helper
112+
|| helper.GetConfiguration().Allows(packageId, source.Url);
113+
114+
internal IManagerSource GetInstalledPackagesSource()
115+
{
116+
var sources = SourcesHelper.GetSources();
117+
if (sources.Count is 0 || sources.Any(source => source.Url == DefaultSource.Url))
118+
return DefaultSource;
119+
120+
return sources[0];
121+
}
96122

97123
protected override IReadOnlyList<Package> _getInstalledPackages_UnSafe()
98124
{
99125
List<Package> Packages = [];
126+
IManagerSource installedSource = GetInstalledPackagesSource();
100127
foreach (
101128
var options in new OverridenInstallationOptions[]
102129
{
@@ -139,7 +166,7 @@ protected override IReadOnlyList<Package> _getInstalledPackages_UnSafe()
139166
p.WaitForExit();
140167
logger.Close(p.ExitCode);
141168

142-
Packages.AddRange(ParseInstalledPackages(outputLines, DefaultSource, this, options));
169+
Packages.AddRange(ParseInstalledPackages(outputLines, installedSource, this, options));
143170
}
144171
return Packages;
145172
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
using System.Diagnostics;
2+
using System.Net.Http.Headers;
3+
using System.Text;
4+
using System.Text.RegularExpressions;
5+
using UniGetUI.Core.Logging;
6+
using UniGetUI.PackageEngine.Classes.Manager;
7+
using UniGetUI.PackageEngine.Classes.Manager.Providers;
8+
using UniGetUI.PackageEngine.Enums;
9+
using UniGetUI.PackageEngine.Interfaces;
10+
using UniGetUI.PackageEngine.ManagerClasses.Classes;
11+
using UniGetUI.PackageEngine.Managers.Generic.NuGet;
12+
13+
namespace UniGetUI.PackageEngine.Managers.DotNetManager;
14+
15+
internal sealed record NuGetConfiguredSource(string Key, Uri Url);
16+
17+
internal sealed class DotNetNuGetConfiguration
18+
{
19+
private readonly IReadOnlyList<(string Path, DateTime? LastWriteTimeUtc)> _files;
20+
private readonly DateTime _loadedAtUtc;
21+
private DateTime _filesCheckedAtUtc;
22+
23+
public DotNetNuGetConfiguration(
24+
IReadOnlyList<IManagerSource> sources,
25+
IReadOnlyList<NuGetConfiguredSource> configuredSources,
26+
NuGetSourceMapping mapping,
27+
IReadOnlyList<string> files,
28+
bool canBeReused
29+
)
30+
{
31+
Sources = sources;
32+
ConfiguredSources = configuredSources;
33+
Mapping = mapping;
34+
CanBeReused = canBeReused;
35+
_files = [.. files.Select(path => (path, LastWriteTime(path)))];
36+
_loadedAtUtc = DateTime.UtcNow;
37+
_filesCheckedAtUtc = _loadedAtUtc;
38+
}
39+
40+
public IReadOnlyList<IManagerSource> Sources { get; }
41+
public IReadOnlyList<NuGetConfiguredSource> ConfiguredSources { get; }
42+
public NuGetSourceMapping Mapping { get; }
43+
public bool CanBeReused { get; }
44+
45+
public bool IsCurrent(TimeSpan maxAge, TimeSpan fileCheckInterval)
46+
{
47+
DateTime now = DateTime.UtcNow;
48+
if (!CanBeReused || now - _loadedAtUtc >= maxAge)
49+
return false;
50+
51+
if (now - _filesCheckedAtUtc < fileCheckInterval)
52+
return true;
53+
54+
if (_files.Any(file => LastWriteTime(file.Path) != file.LastWriteTimeUtc))
55+
return false;
56+
57+
_filesCheckedAtUtc = now;
58+
return true;
59+
}
60+
61+
public bool Allows(string packageId, Uri sourceUrl)
62+
{
63+
if (!Mapping.IsEnabled)
64+
return true;
65+
66+
IReadOnlySet<string> allowed = Mapping.SourcesFor(packageId);
67+
return ConfiguredSources.Any(source =>
68+
source.Url == sourceUrl && allowed.Contains(source.Key)
69+
);
70+
}
71+
72+
private static DateTime? LastWriteTime(string path) =>
73+
File.Exists(path) ? File.GetLastWriteTimeUtc(path) : null;
74+
}
75+
76+
internal sealed partial class DotNetSourceHelper : ReadOnlySourceHelper
77+
{
78+
private static readonly TimeSpan MaxConfigurationAge = TimeSpan.FromMinutes(10);
79+
private static readonly TimeSpan ConfigurationFileCheckInterval = TimeSpan.FromSeconds(5);
80+
81+
private readonly Lock _configurationLock = new();
82+
private DotNetNuGetConfiguration? _configuration;
83+
84+
public DotNetSourceHelper(DotNet manager)
85+
: base(manager, "NuGet sources are read from the NuGet configuration.") { }
86+
87+
protected override IReadOnlyList<IManagerSource> GetSources_UnSafe() =>
88+
GetConfiguration().Sources;
89+
90+
internal DotNetNuGetConfiguration GetConfiguration()
91+
{
92+
lock (_configurationLock)
93+
{
94+
if (_configuration is { } current && current.IsCurrent(MaxConfigurationAge, ConfigurationFileCheckInterval))
95+
return current;
96+
97+
_configuration = LoadConfiguration();
98+
return _configuration;
99+
}
100+
}
101+
102+
private DotNetNuGetConfiguration LoadConfiguration()
103+
{
104+
try
105+
{
106+
var (sourcesExitCode, sourceLines) = RunNuGet("list source --format Detailed");
107+
IReadOnlyList<NuGetConfiguredSource>? configured =
108+
sourcesExitCode is 0 ? ParseSources(sourceLines) : null;
109+
if (configured is null)
110+
{
111+
Logger.Warn(
112+
$"Could not read the NuGet sources of {Manager.Name}, falling back to {Manager.Properties.DefaultSource.Name}"
113+
);
114+
return Fallback();
115+
}
116+
117+
var (pathsExitCode, pathLines) = RunNuGet("config paths");
118+
IReadOnlyList<string> files =
119+
pathsExitCode is 0
120+
? [.. pathLines.Select(line => line.Trim()).Where(File.Exists)]
121+
: [];
122+
NuGetConfigSettings settings = NuGetConfigFiles.Read(files);
123+
124+
NuGetFeedCredentials.Replace(BuildCredentials(configured, settings));
125+
126+
return new DotNetNuGetConfiguration(
127+
ToManagerSources(configured),
128+
configured,
129+
settings.Mapping,
130+
files,
131+
canBeReused: true
132+
);
133+
}
134+
catch (Exception ex)
135+
{
136+
Logger.Warn($"Could not read the NuGet configuration of {Manager.Name}");
137+
Logger.Warn(ex);
138+
return Fallback();
139+
}
140+
}
141+
142+
private DotNetNuGetConfiguration Fallback() =>
143+
new(
144+
[Manager.Properties.DefaultSource],
145+
[new NuGetConfiguredSource(Manager.Properties.DefaultSource.Name, Manager.Properties.DefaultSource.Url)],
146+
NuGetSourceMapping.None,
147+
[],
148+
canBeReused: false
149+
);
150+
151+
internal IReadOnlyList<IManagerSource> ToManagerSources(
152+
IReadOnlyList<NuGetConfiguredSource> configured
153+
)
154+
{
155+
IManagerSource defaultSource = Manager.Properties.DefaultSource;
156+
List<IManagerSource> sources = [];
157+
foreach (NuGetConfiguredSource source in configured)
158+
{
159+
if (source.Url == defaultSource.Url)
160+
{
161+
if (!sources.Contains(defaultSource))
162+
sources.Add(defaultSource);
163+
}
164+
else
165+
{
166+
sources.Add(new ManagerSource(Manager, source.Key, source.Url));
167+
}
168+
}
169+
170+
return sources;
171+
}
172+
173+
internal static IEnumerable<(Uri Source, AuthenticationHeaderValue Header)> BuildCredentials(
174+
IReadOnlyList<NuGetConfiguredSource> configured,
175+
NuGetConfigSettings settings
176+
)
177+
{
178+
foreach (NuGetConfiguredSource source in configured)
179+
{
180+
if (
181+
source.Url.Scheme is not ("http" or "https")
182+
|| !settings.Credentials.TryGetValue(source.Key, out NuGetCredential? credential)
183+
)
184+
continue;
185+
186+
string token = Convert.ToBase64String(
187+
Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}")
188+
);
189+
yield return (source.Url, new AuthenticationHeaderValue("Basic", token));
190+
}
191+
}
192+
193+
private (int ExitCode, List<string> Lines) RunNuGet(string arguments)
194+
{
195+
using Process p = new()
196+
{
197+
StartInfo = new ProcessStartInfo
198+
{
199+
FileName = Manager.Status.ExecutablePath,
200+
Arguments = "nuget " + arguments,
201+
RedirectStandardOutput = true,
202+
RedirectStandardError = true,
203+
UseShellExecute = false,
204+
CreateNoWindow = true,
205+
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
206+
StandardOutputEncoding = Encoding.UTF8,
207+
},
208+
};
209+
p.StartInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en";
210+
211+
IProcessTaskLogger logger = Manager.TaskLogger.CreateNew(LoggableTaskType.ListSources, p);
212+
p.Start();
213+
214+
Task<string> stdErr = p.StandardError.ReadToEndAsync();
215+
List<string> lines = [];
216+
string? line;
217+
while ((line = p.StandardOutput.ReadLine()) is not null)
218+
{
219+
logger.AddToStdOut(line);
220+
lines.Add(line);
221+
}
222+
223+
logger.AddToStdErr(stdErr.GetAwaiter().GetResult());
224+
p.WaitForExit();
225+
logger.Close(p.ExitCode);
226+
return (p.ExitCode, lines);
227+
}
228+
229+
internal static IReadOnlyList<NuGetConfiguredSource>? ParseSources(IEnumerable<string> lines)
230+
{
231+
List<NuGetConfiguredSource> sources = [];
232+
string? pendingName = null;
233+
234+
foreach (string line in lines)
235+
{
236+
Match header = SourceHeader().Match(line);
237+
if (header.Success)
238+
{
239+
pendingName = header.Groups["state"].Value == "Enabled"
240+
? header.Groups["name"].Value
241+
: null;
242+
continue;
243+
}
244+
245+
if (NumberedEntry().IsMatch(line))
246+
return null;
247+
248+
if (pendingName is null || line.Trim().Length is 0)
249+
continue;
250+
251+
if (Uri.TryCreate(line.Trim(), UriKind.Absolute, out Uri? url))
252+
sources.Add(new NuGetConfiguredSource(pendingName, url));
253+
254+
pendingName = null;
255+
}
256+
257+
return sources;
258+
}
259+
260+
[GeneratedRegex(@"^\s*\d+\.\s+(?<name>.*\S)\s+\[(?<state>Enabled|Disabled)\]\s*$")]
261+
private static partial Regex SourceHeader();
262+
263+
[GeneratedRegex(@"^\s*\d+\.\s+\S")]
264+
private static partial Regex NumberedEntry();
265+
}

0 commit comments

Comments
 (0)