dependabot-nuget 0.266.0 → 0.268.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (21) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/lib/NuGetUpdater/NuGetUpdater.Cli.Test/EntryPointTests.Analyze.cs +173 -0
  3. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Analyze/AnalyzeWorker.cs +27 -10
  4. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Analyze/NuGetContext.cs +12 -1
  5. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Analyze/Requirement.cs +88 -45
  6. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/ErrorType.cs +1 -0
  7. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/MissingFileException.cs +11 -0
  8. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/BindingRedirectManager.cs +45 -42
  9. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/PackagesConfigUpdater.cs +1 -0
  10. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/SdkPackageUpdater.cs +16 -12
  11. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Updater/UpdaterWorker.cs +8 -0
  12. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core/Utilities/MSBuildHelper.cs +49 -14
  13. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Analyze/AnalyzeWorkerTests.cs +2 -0
  14. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Analyze/RequirementTests.cs +1 -0
  15. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/MockNuGetPackage.cs +16 -21
  16. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/UpdateWorkerTests.PackagesConfig.cs +360 -0
  17. data/helpers/lib/NuGetUpdater/NuGetUpdater.Core.Test/Update/UpdateWorkerTests.Sdk.cs +121 -0
  18. data/lib/dependabot/nuget/file_fetcher.rb +29 -10
  19. data/lib/dependabot/nuget/file_updater.rb +17 -9
  20. data/lib/dependabot/nuget/native_helpers.rb +2 -0
  21. metadata +6 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4669e522f1ce196d5657096bdf738e6ab353c5a697ea1b0acb1f0c87f9815a44
4
- data.tar.gz: 33657445578fcd52b0370490b3c1f4b2fe165ad9f00e22d831d6a216b89458f5
3
+ metadata.gz: 3dbd572d869f156e22c4020848387b9b54611ad236c08be6b3ca4cce3e5e7ebc
4
+ data.tar.gz: f9578fc6352ff07629255fdd1bb2032cda9b0109090507fc39eed9e1b369a68d
5
5
  SHA512:
6
- metadata.gz: 0ca40a2a42e55b376edc0694253f81a54eb30d75748c3a5572e3a754b64aab96fe86b34f753d0f4566bdf66816da3318aed0eba31fc195581cb2072f89c0d8cb
7
- data.tar.gz: 115640e60d1b2a59edb5b6956c9e8d3c35105dafa6ce00d60aa5de52561da96670606af7862d1a24ab1f24bc4fd91651b4856eeb4b7fc545ca230cc72a529493
6
+ metadata.gz: 8c91cd7ef8d66d4aa48de704d70d41087ad8b6f867946f1cf59038a22f22d06febb5227e637d339fe93ec8fa5c086f8d410e16d00c3f70a4c8c348993de01c28
7
+ data.tar.gz: 161deea8269b8c3fe00013ef83da37e96ddedf9518e9463bdf952034a03ef00be0fac713495b4706ccbcb3d08d072aec700b7921dcbf01506e8e4d432c4fc634
@@ -134,6 +134,179 @@ public partial class EntryPointTests
134
134
  );
135
135
  }
136
136
 
137
+ [Fact]
138
+ public async Task DotNetToolsJsonCanBeAnalyzed()
139
+ {
140
+ var repoMetadata = XElement.Parse("""<repository type="git" url="https://nuget.example.com/some-global-tool" />""");
141
+ await RunAsync(path =>
142
+ [
143
+ "analyze",
144
+ "--repo-root",
145
+ path,
146
+ "--discovery-file-path",
147
+ Path.Join(path, "discovery.json"),
148
+ "--dependency-file-path",
149
+ Path.Join(path, "some-global-tool.json"),
150
+ "--analysis-folder-path",
151
+ Path.Join(path, AnalyzeWorker.AnalysisDirectoryName),
152
+ "--verbose",
153
+ ],
154
+ packages:
155
+ [
156
+ MockNuGetPackage.CreateDotNetToolPackage("some-global-tool", "1.0.0", "net8.0", additionalMetadata: [repoMetadata]),
157
+ MockNuGetPackage.CreateDotNetToolPackage("some-global-tool", "1.1.0", "net8.0", additionalMetadata: [repoMetadata]),
158
+ ],
159
+ dependencyName: "some-global-tool",
160
+ initialFiles:
161
+ [
162
+ (".config/dotnet-tools.json", """
163
+ {
164
+ "version": 1,
165
+ "isRoot": true,
166
+ "tools": {
167
+ "some-global-tool": {
168
+ "version": "1.0.0",
169
+ "commands": [
170
+ "some-global-tool"
171
+ ]
172
+ }
173
+ }
174
+ }
175
+ """),
176
+ ("discovery.json", """
177
+ {
178
+ "Path": "",
179
+ "IsSuccess": true,
180
+ "Projects": [],
181
+ "DotNetToolsJson": {
182
+ "FilePath": ".config/dotnet-tools.json",
183
+ "IsSuccess": true,
184
+ "Dependencies": [
185
+ {
186
+ "Name": "some-global-tool",
187
+ "Version": "1.0.0",
188
+ "Type": "DotNetTool",
189
+ "EvaluationResult": null,
190
+ "TargetFrameworks": null,
191
+ "IsDevDependency": false,
192
+ "IsDirect": false,
193
+ "IsTransitive": false,
194
+ "IsOverride": false,
195
+ "IsUpdate": false,
196
+ "InfoUrl": null
197
+ }
198
+ ]
199
+ }
200
+ }
201
+ """),
202
+ ("some-global-tool.json", """
203
+ {
204
+ "Name": "some-global-tool",
205
+ "Version": "1.0.0",
206
+ "IsVulnerable": false,
207
+ "IgnoredVersions": [],
208
+ "Vulnerabilities": []
209
+ }
210
+ """),
211
+ ],
212
+ expectedResult: new()
213
+ {
214
+ UpdatedVersion = "1.1.0",
215
+ CanUpdate = true,
216
+ VersionComesFromMultiDependencyProperty = false,
217
+ UpdatedDependencies =
218
+ [
219
+ new Dependency("some-global-tool", "1.1.0", DependencyType.DotNetTool, TargetFrameworks: null, IsDirect: true, InfoUrl: "https://nuget.example.com/some-global-tool")
220
+ ],
221
+ }
222
+ );
223
+ }
224
+
225
+ [Fact]
226
+ public async Task GlobalJsonCanBeAnalyzed()
227
+ {
228
+ var repoMetadata = XElement.Parse("""<repository type="git" url="https://nuget.example.com/some.msbuild.sdk" />""");
229
+ await RunAsync(path =>
230
+ [
231
+ "analyze",
232
+ "--repo-root",
233
+ path,
234
+ "--discovery-file-path",
235
+ Path.Join(path, "discovery.json"),
236
+ "--dependency-file-path",
237
+ Path.Join(path, "Some.MSBuild.Sdk.json"),
238
+ "--analysis-folder-path",
239
+ Path.Join(path, AnalyzeWorker.AnalysisDirectoryName),
240
+ "--verbose",
241
+ ],
242
+ packages:
243
+ [
244
+ MockNuGetPackage.CreateMSBuildSdkPackage("Some.MSBuild.Sdk", "1.0.0", "net8.0", additionalMetadata: [repoMetadata]),
245
+ MockNuGetPackage.CreateMSBuildSdkPackage("Some.MSBuild.Sdk", "1.1.0", "net8.0", additionalMetadata: [repoMetadata]),
246
+ ],
247
+ dependencyName: "Some.MSBuild.Sdk",
248
+ initialFiles:
249
+ [
250
+ ("global.json", """
251
+ {
252
+ "sdk": {
253
+ "version": "8.0.300",
254
+ "rollForward": "latestPatch"
255
+ },
256
+ "msbuild-sdks": {
257
+ "Some.MSBuild.Sdk": "1.0.0"
258
+ }
259
+ }
260
+ """),
261
+ ("discovery.json", """
262
+ {
263
+ "Path": "",
264
+ "IsSuccess": true,
265
+ "Projects": [],
266
+ "GlobalJson": {
267
+ "FilePath": "global.json",
268
+ "IsSuccess": true,
269
+ "Dependencies": [
270
+ {
271
+ "Name": "Some.MSBuild.Sdk",
272
+ "Version": "1.0.0",
273
+ "Type": "MSBuildSdk",
274
+ "EvaluationResult": null,
275
+ "TargetFrameworks": null,
276
+ "IsDevDependency": false,
277
+ "IsDirect": false,
278
+ "IsTransitive": false,
279
+ "IsOverride": false,
280
+ "IsUpdate": false,
281
+ "InfoUrl": null
282
+ }
283
+ ]
284
+ }
285
+ }
286
+ """),
287
+ ("Some.MSBuild.Sdk.json", """
288
+ {
289
+ "Name": "Some.MSBuild.Sdk",
290
+ "Version": "1.0.0",
291
+ "IsVulnerable": false,
292
+ "IgnoredVersions": [],
293
+ "Vulnerabilities": []
294
+ }
295
+ """),
296
+ ],
297
+ expectedResult: new()
298
+ {
299
+ UpdatedVersion = "1.1.0",
300
+ CanUpdate = true,
301
+ VersionComesFromMultiDependencyProperty = false,
302
+ UpdatedDependencies =
303
+ [
304
+ new Dependency("Some.MSBuild.Sdk", "1.1.0", DependencyType.MSBuildSdk, TargetFrameworks: null, IsDirect: true, InfoUrl: "https://nuget.example.com/some.msbuild.sdk")
305
+ ],
306
+ }
307
+ );
308
+ }
309
+
137
310
  private static async Task RunAsync(Func<string, string[]> getArgs, string dependencyName, TestFile[] initialFiles, ExpectedAnalysisResult expectedResult, MockNuGetPackage[]? packages = null)
138
311
  {
139
312
  var actualResult = await RunAnalyzerAsync(dependencyName, initialFiles, async path =>
@@ -51,23 +51,21 @@ public partial class AnalyzeWorker
51
51
  => p.Dependencies.Where(d => !d.IsTransitive &&
52
52
  d.EvaluationResult?.RootPropertyName is not null)
53
53
  ).ToImmutableArray();
54
+ var dotnetToolsHasDependency = discovery.DotNetToolsJson?.Dependencies.Any(d => d.Name.Equals(dependencyInfo.Name, StringComparison.OrdinalIgnoreCase)) == true;
55
+ var globalJsonHasDependency = discovery.GlobalJson?.Dependencies.Any(d => d.Name.Equals(dependencyInfo.Name, StringComparison.OrdinalIgnoreCase)) == true;
54
56
 
55
57
  bool usesMultiDependencyProperty = false;
56
58
  NuGetVersion? updatedVersion = null;
57
59
  ImmutableArray<Dependency> updatedDependencies = [];
58
60
 
59
- bool isUpdateNecessary = IsUpdateNecessary(dependencyInfo, projectsWithDependency);
61
+ bool isProjectUpdateNecessary = IsUpdateNecessary(dependencyInfo, projectsWithDependency);
62
+ var isUpdateNecessary = isProjectUpdateNecessary || dotnetToolsHasDependency || globalJsonHasDependency;
60
63
  using var nugetContext = new NuGetContext(startingDirectory);
61
64
  AnalysisResult result;
62
65
  try
63
66
  {
64
67
  if (isUpdateNecessary)
65
68
  {
66
- if (!Directory.Exists(nugetContext.TempPackageDirectory))
67
- {
68
- Directory.CreateDirectory(nugetContext.TempPackageDirectory);
69
- }
70
-
71
69
  _logger.Log($" Determining multi-dependency property.");
72
70
  var multiDependencies = DetermineMultiDependencyDetails(
73
71
  discovery,
@@ -99,16 +97,35 @@ public partial class AnalyzeWorker
99
97
  CancellationToken.None);
100
98
 
101
99
  _logger.Log($" Finding updated peer dependencies.");
102
- updatedDependencies = updatedVersion is not null
103
- ? await FindUpdatedDependenciesAsync(
100
+ if (updatedVersion is null)
101
+ {
102
+ updatedDependencies = [];
103
+ }
104
+ else if (isProjectUpdateNecessary)
105
+ {
106
+ updatedDependencies = await FindUpdatedDependenciesAsync(
104
107
  repoRoot,
105
108
  discovery,
106
109
  dependenciesToUpdate,
107
110
  updatedVersion,
108
111
  nugetContext,
109
112
  _logger,
110
- CancellationToken.None)
111
- : [];
113
+ CancellationToken.None);
114
+ }
115
+ else if (dotnetToolsHasDependency)
116
+ {
117
+ var infoUrl = await nugetContext.GetPackageInfoUrlAsync(dependencyInfo.Name, updatedVersion.ToNormalizedString(), CancellationToken.None);
118
+ updatedDependencies = [new Dependency(dependencyInfo.Name, updatedVersion.ToNormalizedString(), DependencyType.DotNetTool, IsDirect: true, InfoUrl: infoUrl)];
119
+ }
120
+ else if (globalJsonHasDependency)
121
+ {
122
+ var infoUrl = await nugetContext.GetPackageInfoUrlAsync(dependencyInfo.Name, updatedVersion.ToNormalizedString(), CancellationToken.None);
123
+ updatedDependencies = [new Dependency(dependencyInfo.Name, updatedVersion.ToNormalizedString(), DependencyType.MSBuildSdk, IsDirect: true, InfoUrl: infoUrl)];
124
+ }
125
+ else
126
+ {
127
+ throw new InvalidOperationException("Unreachable.");
128
+ }
112
129
 
113
130
  //TODO: At this point we should add the peer dependencies to a queue where
114
131
  // we will analyze them one by one to see if they themselves are part of a
@@ -37,12 +37,23 @@ internal record NuGetContext : IDisposable
37
37
  .Where(p => p.IsEnabled)
38
38
  .ToImmutableArray();
39
39
  Logger = logger ?? NullLogger.Instance;
40
- TempPackageDirectory = Path.Combine(Path.GetTempPath(), ".dependabot", "packages");
40
+ TempPackageDirectory = Path.Combine(Path.GetTempPath(), $"dependabot-packages_{Guid.NewGuid():d}");
41
+ Directory.CreateDirectory(TempPackageDirectory);
41
42
  }
42
43
 
43
44
  public void Dispose()
44
45
  {
45
46
  SourceCacheContext.Dispose();
47
+ if (Directory.Exists(TempPackageDirectory))
48
+ {
49
+ try
50
+ {
51
+ Directory.Delete(TempPackageDirectory, recursive: true);
52
+ }
53
+ catch
54
+ {
55
+ }
56
+ }
46
57
  }
47
58
 
48
59
  private readonly Dictionary<PackageIdentity, string?> _packageInfoUrlCache = new();
@@ -12,7 +12,59 @@ namespace NuGetUpdater.Core.Analyze;
12
12
  /// See Gem::Version for a description on how versions and requirements work
13
13
  /// together in RubyGems.
14
14
  /// </remarks>
15
- public class Requirement
15
+ public abstract class Requirement
16
+ {
17
+ public abstract bool IsSatisfiedBy(NuGetVersion version);
18
+
19
+ private static readonly Dictionary<string, Version> BumpMap = [];
20
+ /// <summary>
21
+ /// Return a new version object where the next to the last revision
22
+ /// number is one greater (e.g., 5.3.1 => 5.4).
23
+ /// </summary>
24
+ /// <remarks>
25
+ /// This logic intended to be similar to RubyGems Gem::Version#bump
26
+ /// </remarks>
27
+ public static Version Bump(NuGetVersion version)
28
+ {
29
+ if (BumpMap.TryGetValue(version.OriginalVersion!, out var bumpedVersion))
30
+ {
31
+ return bumpedVersion;
32
+ }
33
+
34
+ var versionParts = version.OriginalVersion! // Get the original string this version was created from
35
+ .Split('-')[0] // Get the version part without pre-release
36
+ .Split('.') // Split into Major.Minor.Patch.Revision if present
37
+ .Select(int.Parse)
38
+ .ToArray();
39
+
40
+ if (versionParts.Length > 1)
41
+ {
42
+ versionParts = versionParts[..^1]; // Remove the last part
43
+ }
44
+
45
+ versionParts[^1]++; // Increment the new last part
46
+
47
+ bumpedVersion = NuGetVersion.Parse(string.Join('.', versionParts)).Version;
48
+ BumpMap[version.OriginalVersion!] = bumpedVersion;
49
+
50
+ return bumpedVersion;
51
+ }
52
+
53
+ public static Requirement Parse(string requirement)
54
+ {
55
+ var specificParts = requirement.Split(',');
56
+ if (specificParts.Length == 1)
57
+ {
58
+ return IndividualRequirement.ParseIndividual(requirement);
59
+ }
60
+
61
+ var specificRequirements = specificParts.Select(IndividualRequirement.ParseIndividual).ToArray();
62
+ return new MultiPartRequirement(specificRequirements);
63
+ }
64
+ }
65
+
66
+
67
+ public class IndividualRequirement : Requirement
16
68
  {
17
69
  private static readonly ImmutableDictionary<string, Func<NuGetVersion, NuGetVersion, bool>> Operators = new Dictionary<string, Func<NuGetVersion, NuGetVersion, bool>>()
18
70
  {
@@ -25,30 +77,10 @@ public class Requirement
25
77
  ["~>"] = (v, r) => v >= r && v.Version < Bump(r),
26
78
  }.ToImmutableDictionary();
27
79
 
28
- public static Requirement Parse(string requirement)
29
- {
30
- var splitIndex = requirement.LastIndexOfAny(['=', '>', '<']);
31
-
32
- // Throw if the requirement is all operator and no version.
33
- if (splitIndex == requirement.Length - 1)
34
- {
35
- throw new ArgumentException($"`{requirement}` is a invalid requirement string", nameof(requirement));
36
- }
37
-
38
- string[] parts = splitIndex == -1
39
- ? [requirement.Trim()]
40
- : [requirement[..(splitIndex + 1)].Trim(), requirement[(splitIndex + 1)..].Trim()];
41
-
42
- var op = parts.Length == 1 ? "=" : parts[0];
43
- var version = NuGetVersion.Parse(parts[^1]);
44
-
45
- return new Requirement(op, version);
46
- }
47
-
48
80
  public string Operator { get; }
49
81
  public NuGetVersion Version { get; }
50
82
 
51
- public Requirement(string op, NuGetVersion version)
83
+ public IndividualRequirement(string op, NuGetVersion version)
52
84
  {
53
85
  if (!Operators.ContainsKey(op))
54
86
  {
@@ -64,42 +96,53 @@ public class Requirement
64
96
  return $"{Operator} {Version}";
65
97
  }
66
98
 
67
- public bool IsSatisfiedBy(NuGetVersion version)
99
+ public override bool IsSatisfiedBy(NuGetVersion version)
68
100
  {
69
101
  return Operators[Operator](version, Version);
70
102
  }
71
103
 
72
- private static readonly Dictionary<string, Version> BumpMap = [];
73
- /// <summary>
74
- /// Return a new version object where the next to the last revision
75
- /// number is one greater (e.g., 5.3.1 => 5.4).
76
- /// </summary>
77
- /// <remarks>
78
- /// This logic intended to be similar to RubyGems Gem::Version#bump
79
- /// </remarks>
80
- public static Version Bump(NuGetVersion version)
104
+ public static IndividualRequirement ParseIndividual(string requirement)
81
105
  {
82
- if (BumpMap.TryGetValue(version.OriginalVersion!, out var bumpedVersion))
106
+ var splitIndex = requirement.LastIndexOfAny(['=', '>', '<']);
107
+
108
+ // Throw if the requirement is all operator and no version.
109
+ if (splitIndex == requirement.Length - 1)
83
110
  {
84
- return bumpedVersion;
111
+ throw new ArgumentException($"`{requirement}` is a invalid requirement string", nameof(requirement));
85
112
  }
86
113
 
87
- var versionParts = version.OriginalVersion! // Get the original string this version was created from
88
- .Split('-')[0] // Get the version part without pre-release
89
- .Split('.') // Split into Major.Minor.Patch.Revision if present
90
- .Select(int.Parse)
91
- .ToArray();
114
+ string[] parts = splitIndex == -1
115
+ ? [requirement.Trim()]
116
+ : [requirement[..(splitIndex + 1)].Trim(), requirement[(splitIndex + 1)..].Trim()];
92
117
 
93
- if (versionParts.Length > 1)
118
+ var op = parts.Length == 1 ? "=" : parts[0];
119
+ var version = NuGetVersion.Parse(parts[^1]);
120
+
121
+ return new IndividualRequirement(op, version);
122
+ }
123
+ }
124
+
125
+ public class MultiPartRequirement : Requirement
126
+ {
127
+ public ImmutableArray<IndividualRequirement> Parts { get; }
128
+
129
+ public MultiPartRequirement(IndividualRequirement[] parts)
130
+ {
131
+ if (parts.Length <= 1)
94
132
  {
95
- versionParts = versionParts[..^1]; // Remove the last part
133
+ throw new ArgumentException("At least two parts are required", nameof(parts));
96
134
  }
97
135
 
98
- versionParts[^1]++; // Increment the new last part
136
+ Parts = parts.ToImmutableArray();
137
+ }
99
138
 
100
- bumpedVersion = NuGetVersion.Parse(string.Join('.', versionParts)).Version;
101
- BumpMap[version.OriginalVersion!] = bumpedVersion;
139
+ public override string ToString()
140
+ {
141
+ return string.Join(", ", Parts);
142
+ }
102
143
 
103
- return bumpedVersion;
144
+ public override bool IsSatisfiedBy(NuGetVersion version)
145
+ {
146
+ return Parts.All(part => part.IsSatisfiedBy(version));
104
147
  }
105
148
  }
@@ -5,4 +5,5 @@ public enum ErrorType
5
5
  // TODO: add `Unknown` option to track all other failure types
6
6
  None,
7
7
  AuthenticationFailure,
8
+ MissingFile,
8
9
  }
@@ -0,0 +1,11 @@
1
+ namespace NuGetUpdater.Core;
2
+
3
+ internal class MissingFileException : Exception
4
+ {
5
+ public string FilePath { get; }
6
+
7
+ public MissingFileException(string filePath)
8
+ {
9
+ FilePath = filePath;
10
+ }
11
+ }
@@ -149,42 +149,6 @@ internal static class BindingRedirectManager
149
149
  return (element.Name == "None" && string.Equals(path, "app.config", StringComparison.OrdinalIgnoreCase))
150
150
  || (element.Name == "Content" && string.Equals(path, "web.config", StringComparison.OrdinalIgnoreCase));
151
151
  }
152
-
153
- static string GetConfigFileName(XmlDocumentSyntax document)
154
- {
155
- var guidValue = document.Descendants()
156
- .Where(static x => x.Name == "PropertyGroup")
157
- .SelectMany(static x => x.Elements.Where(static x => x.Name == "ProjectGuid"))
158
- .FirstOrDefault()
159
- ?.GetContentValue();
160
- return guidValue switch
161
- {
162
- "{E24C65DC-7377-472B-9ABA-BC803B73C61A}" or "{349C5851-65DF-11DA-9384-00065B846F21}" => "Web.config",
163
- _ => "App.config"
164
- };
165
- }
166
-
167
- static string GenerateDefaultAppConfig(XmlDocumentSyntax document)
168
- {
169
- var frameworkVersion = GetFrameworkVersion(document);
170
- return $"""
171
- <?xml version="1.0" encoding="utf-8" ?>
172
- <configuration>
173
- <startup>
174
- <supportedRuntime version="v4.0" sku=".NETFramework,Version={frameworkVersion}" />
175
- </startup>
176
- </configuration>
177
- """;
178
- }
179
-
180
- static string? GetFrameworkVersion(XmlDocumentSyntax document)
181
- {
182
- return document.Descendants()
183
- .Where(static x => x.Name == "PropertyGroup")
184
- .SelectMany(static x => x.Elements.Where(static x => x.Name == "TargetFrameworkVersion"))
185
- .FirstOrDefault()
186
- ?.GetContentValue();
187
- }
188
152
  }
189
153
 
190
154
  private static string AddBindingRedirects(ConfigurationFile configFile, IEnumerable<Runtime_AssemblyBinding> bindingRedirects)
@@ -213,10 +177,24 @@ internal static class BindingRedirectManager
213
177
 
214
178
  foreach (var bindingRedirect in bindingRedirects)
215
179
  {
216
- // Look to see if we already have this in the list of bindings already in config.
217
- if (currentBindings.TryGetValue((bindingRedirect.Name, bindingRedirect.PublicKeyToken), out var existingBinding))
180
+ // If the binding redirect already exists in config, update it. Otherwise, add it.
181
+ var bindingAssemblyIdentity = new AssemblyIdentity(bindingRedirect.Name, bindingRedirect.PublicKeyToken);
182
+ if (currentBindings.Contains(bindingAssemblyIdentity))
218
183
  {
219
- UpdateBindingRedirectElement(existingBinding, bindingRedirect);
184
+ // Check if there are multiple bindings in config for this assembly and remove all but the first one.
185
+ // Normally there should only be one binding per assembly identity unless the config is malformed, which we'll fix here like NuGet.exe would.
186
+ var existingBindings = currentBindings[bindingAssemblyIdentity];
187
+ if (existingBindings.Any())
188
+ {
189
+ // Remove all but the first element
190
+ foreach (var bindingElement in existingBindings.Skip(1))
191
+ {
192
+ RemoveElement(bindingElement);
193
+ }
194
+
195
+ // Update the first one with the new binding
196
+ UpdateBindingRedirectElement(existingBindings.First(), bindingRedirect);
197
+ }
220
198
  }
221
199
  else
222
200
  {
@@ -228,7 +206,10 @@ internal static class BindingRedirectManager
228
206
  }
229
207
  }
230
208
 
231
- return document.ToString();
209
+ return string.Concat(
210
+ document.Declaration?.ToString() ?? String.Empty, // Ensure the <?xml> declaration node is preserved, if present
211
+ document.ToString()
212
+ );
232
213
 
233
214
  static XDocument GetConfiguration(string configFileContent)
234
215
  {
@@ -278,7 +259,7 @@ internal static class BindingRedirectManager
278
259
  }
279
260
  }
280
261
 
281
- static Dictionary<(string Name, string PublicKeyToken), XElement> GetAssemblyBindings(XElement runtime)
262
+ static ILookup<AssemblyIdentity, XElement> GetAssemblyBindings(XElement runtime)
282
263
  {
283
264
  var dependencyAssemblyElements = runtime.Elements(AssemblyBindingName)
284
265
  .Elements(DependentAssemblyName);
@@ -291,7 +272,12 @@ internal static class BindingRedirectManager
291
272
  });
292
273
 
293
274
  // Return a mapping from binding to element
294
- return assemblyElementPairs.ToDictionary(p => (p.Binding.Name, p.Binding.PublicKeyToken), p => p.Element);
275
+ // It is possible that multiple elements exist for the same assembly identity, so use a lookup (1:*) instead of a dictionary (1:1)
276
+ return assemblyElementPairs.ToLookup(
277
+ p => new AssemblyIdentity(p.Binding.Name, p.Binding.PublicKeyToken),
278
+ p => p.Element,
279
+ new AssemblyIdentityIgnoreCaseComparer()
280
+ );
295
281
  }
296
282
 
297
283
  static XElement GetAssemblyBindingElement(XElement runtime)
@@ -309,4 +295,21 @@ internal static class BindingRedirectManager
309
295
  return assemblyBinding;
310
296
  }
311
297
  }
298
+
299
+ internal sealed record AssemblyIdentity(string Name, string PublicKeyToken);
300
+
301
+ // Case-insensitive comparer. This helps avoid creating duplicate binding redirects when there is a case form mismatch between assembly identities.
302
+ // Especially important for PublicKeyToken which is typically lowercase (using NuGet.exe), but can also be uppercase when using other tools (e.g. Visual Studio auto-resolve assembly conflicts feature).
303
+ internal sealed class AssemblyIdentityIgnoreCaseComparer : IEqualityComparer<AssemblyIdentity>
304
+ {
305
+ public bool Equals(AssemblyIdentity? x, AssemblyIdentity? y) =>
306
+ string.Equals(x?.Name, y?.Name, StringComparison.OrdinalIgnoreCase) &&
307
+ string.Equals(x?.PublicKeyToken, y?.PublicKeyToken, StringComparison.OrdinalIgnoreCase);
308
+
309
+ public int GetHashCode(AssemblyIdentity obj) =>
310
+ HashCode.Combine(
311
+ obj.Name?.ToLowerInvariant(),
312
+ obj.PublicKeyToken?.ToLowerInvariant()
313
+ );
314
+ }
312
315
  }
@@ -146,6 +146,7 @@ internal static class PackagesConfigUpdater
146
146
  }
147
147
 
148
148
  MSBuildHelper.ThrowOnUnauthenticatedFeed(fullOutput);
149
+ MSBuildHelper.ThrowOnMissingFile(fullOutput);
149
150
  throw new Exception(fullOutput);
150
151
  }
151
152
  }