Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 64 additions & 0 deletions src/SingleProject/Resizetizer/src/DetectStaleOutputFilesTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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>
/// 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.
/// </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 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 keep = canonicalizer.CreateSet(KnownOutputs?.Select(i => i.ItemSpec) ?? Enumerable.Empty<string>());

var stale = new List<ITaskItem>();

foreach (var file in Files)
{
if (file is null || string.IsNullOrWhiteSpace(file.ItemSpec))
continue;

if (keep.Contains(canonicalizer.Canonicalize(file.ItemSpec)))
continue;

stale.Add(file);
}

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

StaleFiles = stale.ToArray();

return true;
}
}
}
174 changes: 174 additions & 0 deletions src/SingleProject/Resizetizer/src/PathCanonicalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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>
/// 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>
/// <para>
/// Canonicalization resolves the links of each path segment that already exists and appends the
/// segments that do not, so paths for files which have not been created yet can be canonicalized
/// without the failure a plain <c>realpath</c> would produce.
/// </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;

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

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

readonly Dictionary<string, string> directoryCache = new Dictionary<string, string>(Comparer);

/// <summary>
/// Returns a canonical spelling of <paramref name="path"/>, or the input unchanged when it
/// cannot be canonicalized. Never throws.
/// </summary>
public string Canonicalize(string path)
{
if (string.IsNullOrWhiteSpace(path))
return path;

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

return CanonicalizeFullPath(TrimTrailingSeparators(full), MaxLinkHops);
}

/// <summary>
/// Builds a set of canonical paths that can be probed with <see cref="Canonicalize"/> results.
/// </summary>
public HashSet<string> CreateSet(IEnumerable<string> paths)
{
var set = new HashSet<string>(Comparer);

if (paths is not null)
{
foreach (var path in paths)
{
if (!string.IsNullOrWhiteSpace(path))
set.Add(Canonicalize(path));
}
}

return set;
}

string CanonicalizeFullPath(string full, int hops)
{
var parent = Path.GetDirectoryName(full);
var name = Path.GetFileName(full);

// A root such as "/" or "C:\" has nothing left to resolve.
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
return full;

return ResolveLink(Path.Combine(CanonicalizeDirectory(parent, hops), name), hops);
}

string CanonicalizeDirectory(string directory, int hops)
{
directory = TrimTrailingSeparators(directory);

if (directoryCache.TryGetValue(directory, out var cached))
return cached;

var canonical = CanonicalizeFullPath(directory, hops);
directoryCache[directory] = canonical;
return canonical;
}

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

var target = GetLinkTarget(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, so canonicalize it too.
return CanonicalizeFullPath(resolved, hops - 1);
}

static FileSystemInfo GetLinkTarget(string path)
{
// Directory/File.ResolveLinkTarget only exist 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 simply unavailable and comparison falls back to the lexical full path.
var resolve = Directory.Exists(path)
? ResolveDirectoryLinkTarget
: File.Exists(path)
? ResolveFileLinkTarget
: null;

if (resolve is null)
return null;

try
{
return resolve.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,17 @@
<_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, separators,
casing), and a textual Remove would then classify every generated image as stale. -->
<DetectStaleOutputFilesTask
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 +922,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