Skip to content

Commit 4b8928a

Browse files
committed
feat: implement filesystem sweep for stale DLL versions in NuGetPackageInstaller
1 parent efd9b54 commit 4b8928a

3 files changed

Lines changed: 374 additions & 0 deletions

File tree

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Editor/DependencyResolver/NuGetPackageInstaller.cs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using System.Collections.Generic;
1414
using System.IO;
1515
using System.Linq;
16+
using System.Text.RegularExpressions;
1617
using UnityEngine;
1718

1819
namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver
@@ -126,6 +127,22 @@ internal static bool Install(NuGetPackage package, string installPath, HashSet<s
126127
// operate on the same filenames the collision check would compare against.
127128
var planned = NuGetExtractor.PlanDllPaths(nupkgPath, installPath, package.Version);
128129

130+
// Filesystem-based stale-version sweep. The manifest-driven
131+
// RemoveStaleSiblingVersions above only catches stale DLLs the
132+
// manifest knows about — if the manifest was deleted, never
133+
// existed, or got out of sync with disk, a stale
134+
// <c>{stem}.&lt;olderVersion&gt;.dll</c> would survive and Unity
135+
// would load it alongside the freshly extracted current-version
136+
// copy (duplicate-assembly errors). For each DLL stem this
137+
// package ships, scan the install root for any
138+
// <c>{stem}.dll</c> or <c>{stem}.&lt;numericVersion&gt;.dll</c>
139+
// whose version doesn't match the current package version, and
140+
// remove it (along with its .meta sidecar). The current-version
141+
// canonical filename is preserved — it will be overwritten by
142+
// extraction or already up to date.
143+
if (RemoveStaleVersionDllsByStem(installPath, planned, package.Version, package.Id))
144+
anyInstalled = true;
145+
129146
// Disaster-recovery reconciliation: TryRebuildFromDisk has no way to recover
130147
// multi-DLL package IDs from filenames alone (e.g., Microsoft.Bcl.Memory ships
131148
// System.Memory.dll / System.Buffers.dll / System.Runtime.CompilerServices.Unsafe.dll),
@@ -349,6 +366,102 @@ internal static bool RemoveStaleSiblingVersions(string installPath, string packa
349366
return true;
350367
}
351368

369+
/// <summary>
370+
/// Filesystem-driven complement to <see cref="RemoveStaleSiblingVersions"/>.
371+
/// For every DLL stem the package ships (read off the .nupkg via
372+
/// <paramref name="planned"/>), scans <paramref name="installPath"/>
373+
/// for any sibling that matches <c>{stem}.dll</c> or
374+
/// <c>{stem}.&lt;numericVersion&gt;.dll</c> at a version OTHER than
375+
/// <paramref name="keepVersion"/>, and removes it together with its
376+
/// <c>.meta</c> sidecar. The canonical
377+
/// <c>{stem}.{keepVersion}.dll</c> filename is intentionally
378+
/// preserved — extraction below will overwrite it (or short-circuit
379+
/// via the <c>alreadyOnDisk</c> gate when nothing changed).
380+
///
381+
/// <para>
382+
/// Why we need both this and the manifest-driven scan: when the
383+
/// manifest is missing, corrupted, or has drifted out of sync with
384+
/// disk (manual deletion, partial-restore failure, AV quarantine),
385+
/// the manifest-driven cleanup has nothing to act on and a stale
386+
/// <c>{stem}.&lt;olderVersion&gt;.dll</c> from a prior install
387+
/// survives. Unity then sees both the stale and the freshly
388+
/// extracted current-version copy, registers the same assembly
389+
/// manifest name twice, and the project breaks with CS0436 /
390+
/// CS0433 duplicate-assembly errors. The filesystem sweep is the
391+
/// authoritative pass that catches that case independently of any
392+
/// manifest state.
393+
/// </para>
394+
///
395+
/// <para>
396+
/// Cross-stem safety: the regex anchors on the DLL stem followed
397+
/// either by <c>.dll</c> directly or by a strictly numeric version
398+
/// tail (<c>\d+(\.\d+){0,3}</c>). A package shipping <c>Foo.dll</c>
399+
/// cannot accidentally match <c>Foo.Bar.10.0.0.dll</c> because
400+
/// <c>Bar.10.0.0</c> isn't numeric-only. A package shipping
401+
/// <c>Foo.dll</c> at version 1.0.0 also won't delete a different
402+
/// package's <c>Foo.dll</c> at 2.0.0 if both somehow ended up in
403+
/// the same install path — but that scenario means two packages
404+
/// claiming the same assembly stem at incompatible versions, which
405+
/// would already be a project-breaking duplicate even without this
406+
/// sweep, and the loud delete log makes the conflict visible.
407+
/// </para>
408+
///
409+
/// Returns true when at least one file was removed.
410+
/// </summary>
411+
internal static bool RemoveStaleVersionDllsByStem(string installPath, IReadOnlyList<PlannedDll> planned, string keepVersion, string packageId)
412+
{
413+
if (!Directory.Exists(installPath) || planned.Count == 0)
414+
return false;
415+
416+
// Original DLL stems shipped by the package — keyed off the .nupkg
417+
// entry path, not the planned canonical filename, so the regex
418+
// matches whatever the user has on disk regardless of how it got
419+
// there.
420+
var originalStems = planned
421+
.Select(p => Path.GetFileNameWithoutExtension(Path.GetFileName(p.EntryFullName)))
422+
.Where(s => !string.IsNullOrEmpty(s))
423+
.Distinct(StringComparer.OrdinalIgnoreCase)
424+
.ToList();
425+
426+
if (originalStems.Count == 0)
427+
return false;
428+
429+
// Filenames that match the canonical current-version shape — these
430+
// must NOT be deleted. Compared by exact filename (case-insensitive)
431+
// so a stale uppercase variant on Windows still gets cleaned up.
432+
var canonicalNames = new HashSet<string>(
433+
planned.Select(p => p.FileName),
434+
StringComparer.OrdinalIgnoreCase);
435+
436+
var anyRemoved = false;
437+
var existingFiles = Directory.GetFiles(installPath, "*.dll", SearchOption.TopDirectoryOnly);
438+
439+
foreach (var stem in originalStems)
440+
{
441+
// {stem}.dll or {stem}.<numeric-version>.dll (1-4 segments,
442+
// each segment 1-9 digits — System.Version's ceiling).
443+
var pattern = new Regex(
444+
@"^" + Regex.Escape(stem) + @"(\.\d+(?:\.\d+){0,3})?\.dll$",
445+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
446+
447+
foreach (var dllPath in existingFiles)
448+
{
449+
var fileName = Path.GetFileName(dllPath);
450+
if (canonicalNames.Contains(fileName))
451+
continue;
452+
if (!pattern.IsMatch(fileName))
453+
continue;
454+
455+
Debug.Log($"{Tag} Removing stale '{fileName}' from install path — superseded by {packageId} {keepVersion}.");
456+
TryDeleteFile(dllPath);
457+
TryDeleteFile(dllPath + ".meta");
458+
anyRemoved = true;
459+
}
460+
}
461+
462+
return anyRemoved;
463+
}
464+
352465
static void DeleteEntryFiles(string installPath, InstalledPackage entry)
353466
{
354467
foreach (var dll in entry.Dlls)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/*
2+
+------------------------------------------------------------------+
3+
| Author: Ivan Murzak (https://github.com/IvanMurzak) |
4+
| Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) |
5+
| Copyright (c) 2025 Ivan Murzak |
6+
| Licensed under the Apache License, Version 2.0. |
7+
| See the LICENSE file in the project root for more information. |
8+
+------------------------------------------------------------------+
9+
*/
10+
11+
#nullable enable
12+
using System.Collections.Generic;
13+
using System.IO;
14+
using NUnit.Framework;
15+
using com.IvanMurzak.Unity.MCP.Editor.DependencyResolver;
16+
17+
namespace com.IvanMurzak.Unity.MCP.Editor.Tests.DependencyResolverTests
18+
{
19+
/// <summary>
20+
/// Coverage for <see cref="NuGetPackageInstaller.RemoveStaleVersionDllsByStem"/>:
21+
/// the filesystem-driven companion to the manifest-driven
22+
/// <c>RemoveStaleSiblingVersions</c>. Catches stale
23+
/// <c>{stem}.&lt;olderVersion&gt;.dll</c> files (and unversioned
24+
/// <c>{stem}.dll</c> legacy artifacts) that the manifest knows nothing
25+
/// about — manifest deletion, partial-restore failure, AV quarantine, or
26+
/// manual disk edits all leave the manifest out of sync with disk, and
27+
/// without this sweep the resolver would write the new version alongside
28+
/// the stale one and brick the project with duplicate-assembly errors.
29+
/// </summary>
30+
[TestFixture]
31+
public class NuGetPackageInstallerStaleVersionFilesystemSweepTests
32+
{
33+
string _installPath = string.Empty;
34+
35+
[SetUp]
36+
public void SetUp()
37+
{
38+
_installPath = Path.Combine(
39+
Path.GetTempPath(),
40+
"UnityMcp-StaleSweep-" + Path.GetRandomFileName());
41+
Directory.CreateDirectory(_installPath);
42+
}
43+
44+
[TearDown]
45+
public void TearDown()
46+
{
47+
if (Directory.Exists(_installPath))
48+
{
49+
try { Directory.Delete(_installPath, recursive: true); }
50+
catch { /* best-effort cleanup */ }
51+
}
52+
}
53+
54+
[Test]
55+
public void RemoveStaleVersionDllsByStem_DeletesOlderVersionAndItsMeta()
56+
{
57+
// Project upgraded com.IvanMurzak.McpPlugin from 6.2.0 → 6.2.1, but
58+
// the manifest entry was wiped out. The filesystem sweep MUST find
59+
// the orphan McpPlugin.6.2.0.dll and remove it before extraction
60+
// writes McpPlugin.6.2.1.dll alongside.
61+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.0.dll"), "stale");
62+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.0.dll.meta"), "meta");
63+
64+
var planned = new List<PlannedDll>
65+
{
66+
Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"),
67+
};
68+
69+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
70+
_installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin");
71+
72+
Assert.IsTrue(removed);
73+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.0.dll")));
74+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.0.dll.meta")),
75+
".meta sidecar must be deleted alongside its DLL.");
76+
}
77+
78+
[Test]
79+
public void RemoveStaleVersionDllsByStem_DeletesUnversionedLegacyDll()
80+
{
81+
// Even older legacy: a bare {stem}.dll without a version tail —
82+
// produced by some pre-flat-layout manual install. Same removal
83+
// contract: it cannot coexist with the new versioned form.
84+
File.WriteAllText(Path.Combine(_installPath, "ReflectorNet.dll"), "very-legacy");
85+
File.WriteAllText(Path.Combine(_installPath, "ReflectorNet.dll.meta"), "meta");
86+
87+
var planned = new List<PlannedDll>
88+
{
89+
Plan("lib/netstandard2.0/ReflectorNet.dll", "ReflectorNet.5.1.1.dll"),
90+
};
91+
92+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
93+
_installPath, planned, keepVersion: "5.1.1", packageId: "com.IvanMurzak.ReflectorNet");
94+
95+
Assert.IsTrue(removed);
96+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "ReflectorNet.dll")));
97+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "ReflectorNet.dll.meta")));
98+
}
99+
100+
[Test]
101+
public void RemoveStaleVersionDllsByStem_PreservesCanonicalCurrentVersionFile()
102+
{
103+
// The current-version filename must survive the sweep — extraction
104+
// overwrites it (or alreadyOnDisk short-circuits cleanly).
105+
var canonical = "System.Text.Json.8.0.5.dll";
106+
File.WriteAllText(Path.Combine(_installPath, canonical), "current");
107+
File.WriteAllText(Path.Combine(_installPath, canonical + ".meta"), "meta");
108+
109+
var planned = new List<PlannedDll>
110+
{
111+
Plan("lib/netstandard2.0/System.Text.Json.dll", canonical),
112+
};
113+
114+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
115+
_installPath, planned, keepVersion: "8.0.5", packageId: "System.Text.Json");
116+
117+
Assert.IsFalse(removed, "Nothing to clean up when only the canonical file is on disk.");
118+
Assert.IsTrue(File.Exists(Path.Combine(_installPath, canonical)));
119+
Assert.IsTrue(File.Exists(Path.Combine(_installPath, canonical + ".meta")));
120+
}
121+
122+
[Test]
123+
public void RemoveStaleVersionDllsByStem_HandlesMultipleStaleVersionsForSameStem()
124+
{
125+
// Multiple stale versions on disk (e.g. user kept upgrading without
126+
// restart, leaving a chain of unwanted files). All non-canonical
127+
// versions get swept.
128+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.1.0.dll"), "stale");
129+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.0.dll"), "stale");
130+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.dll"), "stale-unversioned");
131+
132+
var planned = new List<PlannedDll>
133+
{
134+
Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"),
135+
};
136+
137+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
138+
_installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin");
139+
140+
Assert.IsTrue(removed);
141+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.1.0.dll")));
142+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.0.dll")));
143+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.dll")));
144+
}
145+
146+
[Test]
147+
public void RemoveStaleVersionDllsByStem_HandlesMultipleStems_ForMultiDllPackage()
148+
{
149+
// A package shipping multiple DLLs (e.g. Microsoft.Bcl.Memory ships
150+
// System.Memory + System.Buffers + System.Runtime.CompilerServices.Unsafe).
151+
// Every shipped stem gets its own scan; stale versions of each are
152+
// removed independently.
153+
File.WriteAllText(Path.Combine(_installPath, "System.Memory.9.0.0.dll"), "stale");
154+
File.WriteAllText(Path.Combine(_installPath, "System.Buffers.9.0.0.dll"), "stale");
155+
File.WriteAllText(Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.5.0.0.dll"), "stale");
156+
157+
var planned = new List<PlannedDll>
158+
{
159+
Plan("lib/netstandard2.0/System.Memory.dll", "System.Memory.10.0.3.dll"),
160+
Plan("lib/netstandard2.0/System.Buffers.dll", "System.Buffers.10.0.3.dll"),
161+
Plan("lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll", "System.Runtime.CompilerServices.Unsafe.10.0.3.dll"),
162+
};
163+
164+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
165+
_installPath, planned, keepVersion: "10.0.3", packageId: "Microsoft.Bcl.Memory");
166+
167+
Assert.IsTrue(removed);
168+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Memory.9.0.0.dll")));
169+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Buffers.9.0.0.dll")));
170+
Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.5.0.0.dll")));
171+
}
172+
173+
[Test]
174+
public void RemoveStaleVersionDllsByStem_DoesNotMatchSiblingPackageDllWithLongerStem()
175+
{
176+
// Cross-stem boundary: a package shipping McpPlugin.dll must NOT
177+
// sweep McpPlugin.Common.<v>.dll on disk — that file belongs to
178+
// a sibling package. The regex requires either ".dll" or a
179+
// numeric version tail directly after the stem; "Common" is
180+
// neither, so the regex's stem boundary holds.
181+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.Common.6.2.0.dll"), "sibling-package");
182+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.Common.6.2.0.dll.meta"), "meta");
183+
184+
var planned = new List<PlannedDll>
185+
{
186+
Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"),
187+
};
188+
189+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
190+
_installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin");
191+
192+
Assert.IsFalse(removed);
193+
Assert.IsTrue(File.Exists(Path.Combine(_installPath, "McpPlugin.Common.6.2.0.dll")),
194+
"DLL belonging to a different package's stem must not be deleted by the McpPlugin sweep.");
195+
}
196+
197+
[Test]
198+
public void RemoveStaleVersionDllsByStem_DoesNotMatchNonNumericTailAfterStem()
199+
{
200+
// Defensive: a third-party file the user dropped in like
201+
// McpPlugin.Foo.dll has a non-numeric tail and must survive the
202+
// sweep. (The regex demands a numeric-only version tail.)
203+
File.WriteAllText(Path.Combine(_installPath, "McpPlugin.Foo.dll"), "third-party");
204+
205+
var planned = new List<PlannedDll>
206+
{
207+
Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"),
208+
};
209+
210+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
211+
_installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin");
212+
213+
Assert.IsFalse(removed);
214+
Assert.IsTrue(File.Exists(Path.Combine(_installPath, "McpPlugin.Foo.dll")));
215+
}
216+
217+
[Test]
218+
public void RemoveStaleVersionDllsByStem_NoOpWhenInstallPathIsEmpty()
219+
{
220+
// Brand-new install: nothing on disk yet, sweep is a no-op.
221+
var planned = new List<PlannedDll>
222+
{
223+
Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"),
224+
};
225+
226+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
227+
_installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin");
228+
229+
Assert.IsFalse(removed);
230+
}
231+
232+
[Test]
233+
public void RemoveStaleVersionDllsByStem_NoOpWhenPlannedSetIsEmpty()
234+
{
235+
// Development-only / framework-incompatible package paths produce
236+
// an empty planned set; the sweep must be a no-op so we don't
237+
// accidentally start deleting unrelated DLLs.
238+
File.WriteAllText(Path.Combine(_installPath, "Some.Other.1.0.0.dll"), "unrelated");
239+
240+
var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem(
241+
_installPath, new List<PlannedDll>(), keepVersion: "1.0.0", packageId: "AnyPackage");
242+
243+
Assert.IsFalse(removed);
244+
Assert.IsTrue(File.Exists(Path.Combine(_installPath, "Some.Other.1.0.0.dll")));
245+
}
246+
247+
static PlannedDll Plan(string entryFullName, string canonicalFileName) =>
248+
new PlannedDll(entryFullName, canonicalFileName, Path.Combine("/ignored", canonicalFileName));
249+
}
250+
}

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/DependencyResolver/NuGetPackageInstallerStaleVersionFilesystemSweepTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)