Skip to content

Commit eef0107

Browse files
RedthCopilot
andcommitted
[Resizetizer] Prove the symlink fix across input, output and platform shapes
Extends the coverage added with the fix so the properties it relies on are asserted rather than assumed. Deletion behind links, from every direction: * The project directory is reached through a link (already covered). * Only the intermediate output directory is a link, which is what a redirected obj\ looks like. * Only the source image directory is a link. Ordinary cleanup is not weakened. Verified with two mutations of DetectStaleOutputFilesTask: never reporting a file stale fails 10 tests (8 unit, 2 end to end), so the cleanup path is genuinely guarded. Deletes cannot escape the intermediate root. StaleFiles is returned from the input Files verbatim, never as the canonical spelling, so <Delete> only ever sees paths under the wildcard's root: * StaleFilesAreReturnedVerbatimEvenWhenCanonicalizationChangesTheSpelling * StaleFilesAreAlwaysASubsetOfTheInputFiles * ALinkPointingOutsideTheDirectoryIsReportedByItsInsidePath, which covers a link inside the intermediate directory that points outside it. MSBuild deletes the link and never the file it points at. Returning the canonical path instead fails 8 tests, so this is pinned too. Unix and Windows semantics, each asserted where the code supports it: * Case sensitivity follows the platform, and the comparer choice itself is asserted. On Linux two spellings that differ only by case stay distinct; elsewhere they are the same file, which can only ever keep a stale file rather than delete a live one. * Windows keeps its drive root and accepts both separator characters. * Windows junctions resolve like directory links. .NET cannot create one, so the reparse point is written through DeviceIoControl, and the test skips where junctions are unavailable. * Backslashes stay ordinary characters in Unix paths inside the canonicalizer, while MSBuild normalizes separators before the task ever sees an item spec, which is now asserted rather than assumed. Also covers chained links, idempotence and canonicalizing a bare root. All 14 end-to-end scenarios fail against the previous targets and pass with them. Note that the non-linked variants fail too: Path.GetTempPath on macOS returns a path under /var, which is itself a link to /private/var, so any build from a temp directory reproduced the bug. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9ff34c8 commit eef0107

5 files changed

Lines changed: 445 additions & 21 deletions

File tree

src/SingleProject/Resizetizer/test/UnitTests/DetectStaleOutputFilesTaskTests.cs

Lines changed: 141 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,6 @@ public void RedundantSegmentsDoNotMakeAFileStale()
8888
Assert.Empty(task.StaleFiles);
8989
}
9090

91-
[Fact]
92-
public void AlternateDirectorySeparatorsDoNotMakeAFileStale()
93-
{
94-
var kept = Path.Combine(DestinationDirectory, "drawable", "camera.png");
95-
var spelledDifferently = kept.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
96-
97-
var task = GetNewTask(new[] { spelledDifferently }, new[] { kept });
98-
99-
Assert.True(task.Execute());
100-
Assert.Empty(task.StaleFiles);
101-
}
102-
10391
[Fact]
10492
public void FilesReachedThroughALinkedDirectoryAreNotStale()
10593
{
@@ -147,5 +135,146 @@ public void StaleFilesInALinkedDirectoryAreStillDetected()
147135
Assert.True(task.Execute());
148136
Assert.Equal(stale, Assert.Single(task.StaleFiles).ItemSpec);
149137
}
138+
139+
/// <summary>
140+
/// The item specs handed back are what <c>&lt;Delete&gt;</c> acts on, so they must be the exact
141+
/// strings that came in. Returning the canonical spelling instead would let a delete reach
142+
/// outside the intermediate directory the wildcard was rooted at.
143+
/// </summary>
144+
[Fact]
145+
public void StaleFilesAreReturnedVerbatimEvenWhenCanonicalizationChangesTheSpelling()
146+
{
147+
var physical = Path.Combine(DestinationDirectory, "physical");
148+
var link = Path.Combine(DestinationDirectory, "link");
149+
Directory.CreateDirectory(Path.Combine(physical, "drawable"));
150+
151+
if (!SymbolicLink.TryCreateDirectoryLink(link, physical, out var error))
152+
{
153+
Output.WriteLine($"Skipping: symbolic links are not available on this machine: {error}");
154+
return;
155+
}
156+
157+
var stale = Path.Combine(link, "drawable", "orphan.png");
158+
File.WriteAllText(stale, "image");
159+
160+
var canonicalizer = new PathCanonicalizer();
161+
Assert.NotEqual(stale, canonicalizer.Canonicalize(stale), PathCanonicalizer.Comparer);
162+
163+
var task = GetNewTask(new[] { stale }, System.Array.Empty<string>());
164+
165+
Assert.True(task.Execute());
166+
Assert.Equal(stale, Assert.Single(task.StaleFiles).ItemSpec);
167+
}
168+
169+
/// <summary>
170+
/// A file inside the intermediate directory that is itself a link to somewhere else canonicalizes
171+
/// to a path outside that directory, but it is still reported by its inside spelling, so
172+
/// <c>&lt;Delete&gt;</c> removes the link and never the file it points at.
173+
/// </summary>
174+
[Fact]
175+
public void ALinkPointingOutsideTheDirectoryIsReportedByItsInsidePath()
176+
{
177+
var outside = Path.Combine(DestinationDirectory, "outside");
178+
var intermediate = Path.Combine(DestinationDirectory, "intermediate");
179+
Directory.CreateDirectory(outside);
180+
Directory.CreateDirectory(intermediate);
181+
182+
var target = Path.Combine(outside, "source.png");
183+
File.WriteAllText(target, "not a build output");
184+
185+
var inside = Path.Combine(intermediate, "linked.png");
186+
if (!SymbolicLink.TryCreateFileLink(inside, target, out var error))
187+
{
188+
Output.WriteLine($"Skipping: symbolic links are not available on this machine: {error}");
189+
return;
190+
}
191+
192+
var task = GetNewTask(new[] { inside }, System.Array.Empty<string>());
193+
194+
Assert.True(task.Execute());
195+
Assert.Equal(inside, Assert.Single(task.StaleFiles).ItemSpec);
196+
}
197+
198+
/// <summary>
199+
/// Every returned item has to come from <see cref="DetectStaleOutputFilesTask.Files"/>. Nothing may
200+
/// be invented, and nothing that was declared as an output may be returned.
201+
/// </summary>
202+
[Fact]
203+
public void StaleFilesAreAlwaysASubsetOfTheInputFiles()
204+
{
205+
var files = new[]
206+
{
207+
Path.Combine(DestinationDirectory, "drawable", "camera.png"),
208+
Path.Combine(DestinationDirectory, "drawable", "orphan.png"),
209+
Path.Combine(DestinationDirectory, "drawable-xhdpi", "camera.png"),
210+
};
211+
212+
var known = new[]
213+
{
214+
files[0],
215+
// Declared as an output but not on disk, and a sibling that was never enumerated.
216+
Path.Combine(DestinationDirectory, "drawable-hdpi", "camera.png"),
217+
};
218+
219+
var task = GetNewTask(files, known);
220+
221+
Assert.True(task.Execute());
222+
223+
var stale = task.StaleFiles.Select(f => f.ItemSpec).ToArray();
224+
Assert.All(stale, s => Assert.Contains(s, files));
225+
Assert.Equal(new[] { files[1], files[2] }, stale);
226+
}
227+
228+
[Fact]
229+
public void EmptyAndWhitespaceItemSpecsAreIgnored()
230+
{
231+
var stale = Path.Combine(DestinationDirectory, "orphan.png");
232+
233+
var task = GetNewTask(new[] { "", " ", stale }, new[] { "", " " });
234+
235+
Assert.True(task.Execute());
236+
Assert.Equal(stale, Assert.Single(task.StaleFiles).ItemSpec);
237+
}
238+
239+
[Fact]
240+
public void CaseOnlyDifferencesFollowThePlatformFileSystem()
241+
{
242+
var kept = Path.Combine(DestinationDirectory, "drawable", "camera.png");
243+
var differentCase = Path.Combine(DestinationDirectory, "drawable", "CAMERA.png");
244+
245+
var task = GetNewTask(new[] { differentCase }, new[] { kept });
246+
247+
Assert.True(task.Execute());
248+
249+
if (OperatingSystem.IsLinux())
250+
{
251+
// Linux paths are case sensitive, so these really are two different files.
252+
Assert.Equal(differentCase, Assert.Single(task.StaleFiles).ItemSpec);
253+
}
254+
else
255+
{
256+
// Windows and macOS are treated case insensitively. On a case sensitive macOS volume this
257+
// can only ever keep a stale file, which is much safer than deleting a live one, and
258+
// Resizetizer already rejects output names that differ only by case.
259+
Assert.Empty(task.StaleFiles);
260+
}
261+
}
262+
263+
[Fact]
264+
public void MSBuildNormalizesSeparatorsBeforeTheTaskSeesThem()
265+
{
266+
var kept = Path.Combine(DestinationDirectory, "drawable", "camera.png");
267+
268+
// MSBuild rewrites separators when it builds an item spec: on Unix a backslash becomes a
269+
// forward slash, and on Windows both characters are separators anyway. Either way the task
270+
// only ever receives paths that already use the platform separator, so canonicalization does
271+
// not have to guess which character was meant.
272+
var spelledWithBackslashes = kept.Replace(Path.DirectorySeparatorChar, '\\');
273+
274+
var task = GetNewTask(new[] { spelledWithBackslashes }, new[] { kept });
275+
276+
Assert.True(task.Execute());
277+
Assert.Empty(task.StaleFiles);
278+
}
150279
}
151280
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System;
2+
using System.ComponentModel;
3+
using System.IO;
4+
using System.Runtime.InteropServices;
5+
using System.Runtime.Versioning;
6+
7+
namespace Microsoft.Maui.Resizetizer.Tests
8+
{
9+
/// <summary>
10+
/// A junction is the other kind of Windows directory redirection a project can sit behind, and unlike
11+
/// a symbolic link it does not need Developer Mode or elevation. .NET has no API to create one, so
12+
/// the reparse point is written through the Win32 device control interface.
13+
/// </summary>
14+
[SupportedOSPlatform("windows")]
15+
static class Junction
16+
{
17+
const uint FsctlSetReparsePoint = 0x000900A4;
18+
const uint IoReparseTagMountPoint = 0xA0000003;
19+
const uint GenericWrite = 0x40000000;
20+
const uint FileShareAll = 0x00000001 | 0x00000002 | 0x00000004;
21+
const uint OpenExisting = 3;
22+
const uint FileFlagBackupSemantics = 0x02000000;
23+
const uint FileFlagOpenReparsePoint = 0x00200000;
24+
25+
public static bool TryCreate(string junction, string target, out string error)
26+
{
27+
try
28+
{
29+
Create(junction, target);
30+
error = null;
31+
return true;
32+
}
33+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Win32Exception or PlatformNotSupportedException)
34+
{
35+
error = ex.Message;
36+
return false;
37+
}
38+
}
39+
40+
static void Create(string junction, string target)
41+
{
42+
Directory.CreateDirectory(junction);
43+
44+
// A mount point stores its target as an NT object path.
45+
var printName = Path.GetFullPath(target);
46+
var substituteName = @"\??\" + printName;
47+
48+
var substituteBytes = System.Text.Encoding.Unicode.GetBytes(substituteName);
49+
var printBytes = System.Text.Encoding.Unicode.GetBytes(printName);
50+
51+
// REPARSE_DATA_BUFFER: 8 byte header, 8 bytes of mount point offsets/lengths, then the two
52+
// null terminated names.
53+
var pathBufferLength = substituteBytes.Length + 2 + printBytes.Length + 2;
54+
var buffer = new byte[8 + 8 + pathBufferLength];
55+
56+
BitConverter.GetBytes(IoReparseTagMountPoint).CopyTo(buffer, 0);
57+
BitConverter.GetBytes((ushort)(8 + pathBufferLength)).CopyTo(buffer, 4);
58+
BitConverter.GetBytes((ushort)0).CopyTo(buffer, 6);
59+
BitConverter.GetBytes((ushort)0).CopyTo(buffer, 8);
60+
BitConverter.GetBytes((ushort)substituteBytes.Length).CopyTo(buffer, 10);
61+
BitConverter.GetBytes((ushort)(substituteBytes.Length + 2)).CopyTo(buffer, 12);
62+
BitConverter.GetBytes((ushort)printBytes.Length).CopyTo(buffer, 14);
63+
substituteBytes.CopyTo(buffer, 16);
64+
printBytes.CopyTo(buffer, 16 + substituteBytes.Length + 2);
65+
66+
using var handle = CreateFile(
67+
junction,
68+
GenericWrite,
69+
FileShareAll,
70+
IntPtr.Zero,
71+
OpenExisting,
72+
FileFlagBackupSemantics | FileFlagOpenReparsePoint,
73+
IntPtr.Zero);
74+
75+
if (handle.IsInvalid)
76+
throw new Win32Exception(Marshal.GetLastWin32Error());
77+
78+
if (!DeviceIoControl(handle, FsctlSetReparsePoint, buffer, buffer.Length, IntPtr.Zero, 0, out _, IntPtr.Zero))
79+
throw new Win32Exception(Marshal.GetLastWin32Error());
80+
}
81+
82+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
83+
static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFile(
84+
string lpFileName,
85+
uint dwDesiredAccess,
86+
uint dwShareMode,
87+
IntPtr lpSecurityAttributes,
88+
uint dwCreationDisposition,
89+
uint dwFlagsAndAttributes,
90+
IntPtr hTemplateFile);
91+
92+
[DllImport("kernel32.dll", SetLastError = true)]
93+
[return: MarshalAs(UnmanagedType.Bool)]
94+
static extern bool DeviceIoControl(
95+
Microsoft.Win32.SafeHandles.SafeFileHandle hDevice,
96+
uint dwIoControlCode,
97+
byte[] lpInBuffer,
98+
int nInBufferSize,
99+
IntPtr lpOutBuffer,
100+
int nOutBufferSize,
101+
out int lpBytesReturned,
102+
IntPtr lpOverlapped);
103+
}
104+
}

src/SingleProject/Resizetizer/test/UnitTests/PathCanonicalizerTests.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,118 @@ public void UnrelatedPathsStayDifferent()
122122
Assert.NotEqual(one, two, PathCanonicalizer.Comparer);
123123
}
124124

125+
[Fact]
126+
public void CanonicalizingIsIdempotent()
127+
{
128+
var (physical, link) = CreateLinkedDirectory();
129+
if (link is null)
130+
return;
131+
132+
File.WriteAllText(Path.Combine(physical, "camera.png"), "image");
133+
134+
var canonicalizer = new PathCanonicalizer();
135+
var once = canonicalizer.Canonicalize(Path.Combine(link, "camera.png"));
136+
137+
Assert.Equal(once, canonicalizer.Canonicalize(once), PathCanonicalizer.Comparer);
138+
}
139+
140+
[Fact]
141+
public void ChainedLinksResolveToTheFinalTarget()
142+
{
143+
var physical = Path.Combine(DestinationDirectory, "physical");
144+
var first = Path.Combine(DestinationDirectory, "first");
145+
var second = Path.Combine(DestinationDirectory, "second");
146+
Directory.CreateDirectory(physical);
147+
148+
if (!SymbolicLink.TryCreateDirectoryLink(first, physical, out var error) ||
149+
!SymbolicLink.TryCreateDirectoryLink(second, first, out error))
150+
{
151+
Output.WriteLine($"Skipping: symbolic links are not available on this machine: {error}");
152+
return;
153+
}
154+
155+
var canonicalizer = new PathCanonicalizer();
156+
157+
Assert.Equal(
158+
canonicalizer.Canonicalize(Path.Combine(physical, "camera.png")),
159+
canonicalizer.Canonicalize(Path.Combine(second, "camera.png")),
160+
PathCanonicalizer.Comparer);
161+
}
162+
163+
[Fact]
164+
public void ComparerFollowsThePlatformFileSystem()
165+
{
166+
// Only Linux path comparison is case sensitive. Everywhere else two spellings that differ by
167+
// case are treated as the same file, which can only ever keep a stale file rather than delete
168+
// a live one.
169+
Assert.Equal(OperatingSystem.IsLinux() ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase, PathCanonicalizer.Comparer);
170+
}
171+
172+
[Fact]
173+
public void RootsAreCanonicalizedWithoutRecursingForever()
174+
{
175+
var canonicalizer = new PathCanonicalizer();
176+
var root = Path.GetPathRoot(Path.GetFullPath(DestinationDirectory));
177+
178+
Assert.False(string.IsNullOrEmpty(root));
179+
Assert.Equal(root, canonicalizer.Canonicalize(root), PathCanonicalizer.Comparer);
180+
}
181+
182+
[Fact]
183+
public void WindowsPathsKeepTheirDriveAndAcceptBothSeparators()
184+
{
185+
if (!OperatingSystem.IsWindows())
186+
return;
187+
188+
var canonicalizer = new PathCanonicalizer();
189+
var path = Path.Combine(DestinationDirectory, "drawable", "camera.png");
190+
191+
Assert.Equal(
192+
canonicalizer.Canonicalize(path),
193+
canonicalizer.Canonicalize(path.Replace('\\', '/')),
194+
PathCanonicalizer.Comparer);
195+
196+
Assert.Equal(Path.GetPathRoot(path), Path.GetPathRoot(canonicalizer.Canonicalize(path)), StringComparer.OrdinalIgnoreCase);
197+
}
198+
199+
[Fact]
200+
public void WindowsJunctionsResolveLikeDirectoryLinks()
201+
{
202+
if (!OperatingSystem.IsWindows())
203+
return;
204+
205+
var physical = Path.Combine(DestinationDirectory, "physical");
206+
var junction = Path.Combine(DestinationDirectory, "junction");
207+
Directory.CreateDirectory(physical);
208+
209+
if (!Junction.TryCreate(junction, physical, out var error))
210+
{
211+
Output.WriteLine($"Skipping: junctions are not available on this machine: {error}");
212+
return;
213+
}
214+
215+
var canonicalizer = new PathCanonicalizer();
216+
217+
Assert.Equal(
218+
canonicalizer.Canonicalize(Path.Combine(physical, "camera.png")),
219+
canonicalizer.Canonicalize(Path.Combine(junction, "camera.png")),
220+
PathCanonicalizer.Comparer);
221+
}
222+
223+
[Fact]
224+
public void BackslashesAreOrdinaryCharactersOnUnix()
225+
{
226+
if (OperatingSystem.IsWindows())
227+
return;
228+
229+
var canonicalizer = new PathCanonicalizer();
230+
231+
var withBackslash = canonicalizer.Canonicalize(Path.Combine(DestinationDirectory, "drawable\\camera.png"));
232+
var withSeparator = canonicalizer.Canonicalize(Path.Combine(DestinationDirectory, "drawable", "camera.png"));
233+
234+
Assert.NotEqual(withBackslash, withSeparator, PathCanonicalizer.Comparer);
235+
}
236+
125237
(string Physical, string Link) CreateLinkedDirectory()
126238
{
127239
var physical = Path.Combine(DestinationDirectory, "physical");

0 commit comments

Comments
 (0)