Skip to content
Open
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
95 changes: 95 additions & 0 deletions src/SingleProject/Resizetizer/src/DetectStaleOutputFilesTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.Maui.Resizetizer
{
/// <summary>
/// Returns the subset of <see cref="Files"/> that is not part of <see cref="KnownOutputs"/>, so the
/// build can delete files left over from a previous build without ever deleting a file it just wrote.
/// </summary>
/// <remarks>
/// <para>
/// The two item lists reach this task through different code paths: <see cref="Files"/> comes from an
/// MSBuild wildcard rooted at the project directory, while <see cref="KnownOutputs"/> comes back from
/// a task that resolved the same directory itself. A plain <c>Remove</c> compares those item specs
/// textually, so a symbolic link or junction anywhere in the project path is enough to make every
/// generated file look stale. Comparing canonical paths makes the difference independent of spelling
/// while the returned items keep their original item spec and metadata.
/// </para>
/// <para>
/// A recursive MSBuild wildcard descends through directory links, so it can name a file that lives
/// outside <see cref="Root"/>, and <c>&lt;Delete&gt;</c> would then remove that outside file rather
/// than the link. Anything whose resolved directory escapes <see cref="Root"/> is therefore never
/// reported as stale.
/// </para>
/// </remarks>
public class DetectStaleOutputFilesTask : Task
{
/// <summary>The files currently present in the output directory.</summary>
public ITaskItem[] Files { get; set; }

/// <summary>The files the build expects to be there.</summary>
public ITaskItem[] KnownOutputs { get; set; }

/// <summary>The only directory whose contents this task is allowed to report as stale.</summary>
[Required]
public string Root { get; set; }

/// <summary>The members of <see cref="Files"/> that are safe to delete.</summary>
[Output]
public ITaskItem[] StaleFiles { get; set; }

public override bool Execute()
{
StaleFiles = Array.Empty<ITaskItem>();

if (Files is null || Files.Length == 0)
return true;

var canonicalizer = new PathCanonicalizer();

var root = canonicalizer.CanonicalizeDirectory(Root);
if (string.IsNullOrEmpty(root))
{
Log.LogMessage(MessageImportance.Low, $"Skipping stale file detection because the root '{Root}' could not be resolved.");
return true;
}

var keep = new HashSet<string>(PathCanonicalizer.Comparer);
foreach (var known in KnownOutputs ?? Enumerable.Empty<ITaskItem>())
{
var key = canonicalizer.GetComparisonKey(known?.ItemSpec);
if (key is not null)
keep.Add(key);
}

var stale = new List<ITaskItem>();

foreach (var file in Files)
{
var key = canonicalizer.GetComparisonKey(file?.ItemSpec);
if (key is null)
continue;

if (!PathCanonicalizer.IsUnder(key, root))
{
Log.LogMessage(MessageImportance.Low, $"Leaving '{file.ItemSpec}' alone because it resolves to '{key}', which is outside '{root}'.");
continue;
}

if (keep.Contains(key))
continue;

Log.LogMessage(MessageImportance.Low, $"Detected stale output file '{file.ItemSpec}'.");
stale.Add(file);
}

StaleFiles = stale.ToArray();

return true;
}
}
}
209 changes: 209 additions & 0 deletions src/SingleProject/Resizetizer/src/PathCanonicalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;

namespace Microsoft.Maui.Resizetizer
{
/// <summary>
/// Produces a canonical spelling of a file system path so that two differently spelled paths which
/// point at the same file compare as equal.
/// </summary>
/// <remarks>
/// <para>
/// MSBuild resolves relative item specs against <c>$(MSBuildProjectDirectory)</c>, which keeps the
/// spelling the build was started with, while <see cref="Path.GetFullPath(string)"/> inside a task
/// resolves them against the process working directory, which Unix reports with every symbolic link
/// already resolved. When any segment of the project path is a link (macOS <c>/tmp</c> and
/// <c>/var/folders</c>, or a Windows junction) the two spellings differ, so a set difference between
/// MSBuild items and task outputs wrongly reports freshly written files as stale.
/// </para>
/// <para>
/// Only the <em>directory</em> part of a path is link resolved. The file name is kept verbatim, so two
/// different names in one directory never collapse into one even when one of them is a link to the
/// other. Resolving the leaf as well would let a stale alias masquerade as a live output and survive
/// cleanup forever.
/// </para>
/// <para>
/// Directories that do not exist yet are appended unresolved, so a path for a file the build has not
/// written can be canonicalized without the failure a plain <c>realpath</c> would produce.
/// </para>
/// <para>
/// The canonical form is only ever used for comparison. Callers keep the original item spec so the
/// paths surfaced to the rest of the build stay in the spelling the user provided.
/// </para>
/// </remarks>
internal sealed class PathCanonicalizer
{
/// <summary>Bounds link resolution so that a cycle of links cannot hang the build.</summary>
const int MaxLinkHops = 40;

/// <summary>
/// Paths are compared case insensitively everywhere except Linux. macOS volumes can be case
/// sensitive, but treating two spellings as the same file there only ever means a stale file is
/// kept, which is far safer than deleting a file that is still needed.
/// </summary>
public static StringComparer Comparer { get; } =
RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? StringComparer.Ordinal
: StringComparer.OrdinalIgnoreCase;

/// <summary>The <see cref="StringComparison"/> matching <see cref="Comparer"/>.</summary>
public static StringComparison Comparison { get; } =
RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;

static readonly MethodInfo ResolveDirectoryLinkTarget =
typeof(Directory).GetMethod("ResolveLinkTarget", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string), typeof(bool) }, null);

// Cache keys are exact spellings. Two directories whose names differ only by case can be two
// different directories on a case sensitive volume, so a case insensitive key could hand back
// another directory's resolved target.
readonly Dictionary<string, string> directoryCache = new Dictionary<string, string>(StringComparer.Ordinal);

/// <summary>
/// Returns the key to compare <paramref name="path"/> by: its link resolved directory plus its
/// file name unchanged. Returns <see langword="null"/> when the path cannot be interpreted.
/// </summary>
public string GetComparisonKey(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;

string full;
try
{
full = TrimTrailingSeparators(Path.GetFullPath(path));
}
catch (Exception)
{
// An item spec can contain characters that are not valid in a path.
return null;
}

var parent = Path.GetDirectoryName(full);
var name = Path.GetFileName(full);

// A bare root such as "/" or "C:\" has no file name to keep.
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
return CanonicalizeDirectory(full);

var directory = CanonicalizeDirectory(parent);

return directory is null ? null : Path.Combine(directory, name);
}

/// <summary>
/// Returns <paramref name="directory"/> with every link in it resolved, or <see langword="null"/>
/// when it cannot be interpreted. Segments that do not exist are kept as they are.
/// </summary>
public string CanonicalizeDirectory(string directory)
{
if (string.IsNullOrWhiteSpace(directory))
return null;

string full;
try
{
full = TrimTrailingSeparators(Path.GetFullPath(directory));
}
catch (Exception)
{
return null;
}

return Canonicalize(full, MaxLinkHops);
}

/// <summary>
/// Returns whether <paramref name="key"/> names something inside <paramref name="root"/>. Both
/// must already be comparison keys.
/// </summary>
public static bool IsUnder(string key, string root)
{
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(root))
return false;

if (Comparer.Equals(key, root))
return true;

if (key.Length <= root.Length || !key.StartsWith(root, Comparison))
return false;

// A root that already ends in a separator, such as "/" or "C:\", has no separator to skip.
var last = root[root.Length - 1];
if (last == Path.DirectorySeparatorChar || last == Path.AltDirectorySeparatorChar)
return true;

// Guard against "…/r-backup" being treated as living inside "…/r".
var next = key[root.Length];
return next == Path.DirectorySeparatorChar || next == Path.AltDirectorySeparatorChar;
}

string Canonicalize(string full, int hops)
{
if (directoryCache.TryGetValue(full, out var cached))
return cached;

var parent = Path.GetDirectoryName(full);
var name = Path.GetFileName(full);

// A root resolves to itself, which also terminates the walk.
var canonical = string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name)
? full
: ResolveDirectoryLink(Path.Combine(Canonicalize(parent, hops), name), hops);

directoryCache[full] = canonical;
return canonical;
}

string ResolveDirectoryLink(string path, int hops)
{
if (hops <= 0)
return path;

var target = GetDirectoryLinkTarget(path);
if (target is null)
return path;

var resolved = TrimTrailingSeparators(target.FullName);
if (Comparer.Equals(resolved, path))
return path;

// The target itself may live under directories that are links.
return Canonicalize(resolved, hops - 1);
}

static FileSystemInfo GetDirectoryLinkTarget(string path)
{
// Directory.ResolveLinkTarget only exists on .NET 6 and later. This assembly targets
// netstandard2.0 so that it can also load into MSBuild.exe on .NET Framework, where link
// resolution is unavailable and comparison falls back to the lexical full path.
if (ResolveDirectoryLinkTarget is null || !Directory.Exists(path))
return null;

try
{
return ResolveDirectoryLinkTarget.Invoke(null, new object[] { path, /* returnFinalTarget: */ true }) as FileSystemInfo;
}
catch (Exception)
{
// Cyclic links, missing permissions or a racing delete: keep the unresolved spelling.
return null;
}
}

static string TrimTrailingSeparators(string path)
{
var root = Path.GetPathRoot(path) ?? string.Empty;

var end = path.Length;
while (end > root.Length && (path[end - 1] == Path.DirectorySeparatorChar || path[end - 1] == Path.AltDirectorySeparatorChar))
end--;

return end == path.Length ? path : path.Substring(0, end);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
AssemblyFile="$(_ResizetizerTaskAssemblyName)"
TaskName="Microsoft.Maui.Resizetizer.DetectInvalidResourceOutputFilenamesTask" />

<UsingTask
AssemblyFile="$(_ResizetizerTaskAssemblyName)"
TaskName="Microsoft.Maui.Resizetizer.DetectStaleOutputFilesTask" />

<UsingTask
AssemblyFile="$(_ResizetizerTaskAssemblyName)"
TaskName="Microsoft.Maui.Resizetizer.CreatePartialInfoPlistTask" />
Expand Down Expand Up @@ -90,6 +94,14 @@
<_MauiIntermediateSplashScreen>$(_ResizetizerIntermediateOutputRoot)sp\</_MauiIntermediateSplashScreen>
<_MauiIntermediateManifest>$(_ResizetizerIntermediateOutputRoot)m\</_MauiIntermediateManifest>

<!-- Rooted against the project directory rather than with [System.IO.Path]::GetFullPath, which
resolves against the process working directory. Unix reports that directory with every
symbolic link already resolved, so GetFullPath would spell the intermediate directory
differently from %(FullPath)/$(MSBuildProjectDirectory) whenever the project lives behind a
link (macOS /tmp and /var/folders, or a Windows junction). Handing this spelling to the
tasks keeps every path the build produces consistent with the paths MSBuild produces. -->
<_MauiIntermediateImagesFullPath>$([MSBuild]::EnsureTrailingSlash($([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(_MauiIntermediateImages)'))))</_MauiIntermediateImagesFullPath>

<ResizetizerIncludeSelfProject Condition="'$(ResizetizerIncludeSelfProject)' == ''">False</ResizetizerIncludeSelfProject>

<_ResizetizerDefaultInvalidFilenamesErrorMessage>One or more invalid file names were detected. File names must be lowercase, start and end with a letter character, and contain only alphanumeric characters or underscores: </_ResizetizerDefaultInvalidFilenamesErrorMessage>
Expand Down Expand Up @@ -785,7 +797,7 @@
ThrowsErrorOnDuplicateOutput="$(_ResizetizerThrowsErrorOnDuplicateOutputFilename)"
DuplicateOutputErrorMessage="$(_ResizetizerDefaultDuplicateFilenamesErrorMessage)"
PlatformType="$(ResizetizerPlatformType)"
IntermediateOutputPath="$(_MauiIntermediateImages)"
IntermediateOutputPath="$(_MauiIntermediateImagesFullPath)"
InputsFile="$(_ResizetizerInputsFile)"
Images="@(_MauiImageToProcess)">
<Output TaskParameter="CopiedResources" ItemName="_CopiedResources" />
Expand All @@ -800,10 +812,20 @@
<_ResizetizerCollectedImages Condition="'@(_CopiedResources)' == '' And '@(_MauiImageToProcess)' != ''" Include="@(_ResizetizerOutputs)" />
<!-- Wildcard is kept solely for stale-file detection and cleanup. -->
<_ResizetizerExistingImages Include="$(_MauiIntermediateImages)\**\*" />
<_ResizetizerImagesToDelete Include="@(_ResizetizerExistingImages->'%(FullPath)')" />
<_ResizetizerImagesToDelete Remove="@(_ResizetizerCollectedImages)" />
</ItemGroup>

<!-- Compare canonical paths rather than item specs. The wildcard above and the collected images
can legitimately spell the same file differently (symlinked project paths, casing), and a
textual Remove would then classify every generated image as stale. Root also confines the
delete list to the intermediate folder, because the recursive wildcard descends through any
directory link it finds inside it. -->
<DetectStaleOutputFilesTask
Root="$(_MauiIntermediateImagesFullPath)"
Files="@(_ResizetizerExistingImages->'%(FullPath)')"
KnownOutputs="@(_ResizetizerCollectedImages)">
<Output TaskParameter="StaleFiles" ItemName="_ResizetizerImagesToDelete" />
</DetectStaleOutputFilesTask>

<!-- Remove files which are no longer needed -->
<Delete
Condition="'@(_ResizetizerImagesToDelete->Count())' != '0'"
Expand Down Expand Up @@ -903,14 +925,14 @@

<!-- Tizen -->
<PropertyGroup>
<ResizetizerIntermediateOutputAbsolutePath>$([System.IO.Path]::GetFullPath('$(_MauiIntermediateImages)'))</ResizetizerIntermediateOutputAbsolutePath>
<ResizetizerIntermediateOutputAbsolutePath>$(_MauiIntermediateImagesFullPath)</ResizetizerIntermediateOutputAbsolutePath>
</PropertyGroup>
<ItemGroup Condition="'$(_ResizetizerIsTizenApp)' == 'True' And '@(_ResizetizerCollectedImages)' != ''">
<TizenTpkUserIncludeFiles Include="$(ResizetizerIntermediateOutputAbsolutePath)res\res.xml" TizenTpkSubDir="res\" />
<FileWrites Include="$(ResizetizerIntermediateOutputAbsolutePath)res\res.xml" />

<TizenTpkUserIncludeFiles Include="@(_ResizetizerCollectedImages)">
<TizenTpkSubDir>$([MSBuild]::MakeRelative($(ResizetizerIntermediateOutputAbsolutePath), $([System.IO.Path]::GetFullPath('%(_ResizetizerCollectedImages.RelativeDir)'))))</TizenTpkSubDir>
<TizenTpkSubDir>$([MSBuild]::MakeRelative('$(ResizetizerIntermediateOutputAbsolutePath)', '%(_ResizetizerCollectedImages.RelativeDir)'))</TizenTpkSubDir>
</TizenTpkUserIncludeFiles>
</ItemGroup>

Expand Down
Loading
Loading