77namespace Microsoft . Maui . Resizetizer
88{
99 /// <summary>
10- /// Produces a canonical spelling of a file system path so that two differently spelled paths
11- /// which point at the same file compare as equal.
10+ /// Produces a canonical spelling of a file system path so that two differently spelled paths which
11+ /// point at the same file compare as equal.
1212 /// </summary>
1313 /// <remarks>
1414 /// <para>
@@ -20,13 +20,18 @@ namespace Microsoft.Maui.Resizetizer
2020 /// MSBuild items and task outputs wrongly reports freshly written files as stale.
2121 /// </para>
2222 /// <para>
23- /// The canonical form is only ever used for comparison. Callers keep the original item spec so the
24- /// paths surfaced to the rest of the build stay in the spelling the user provided.
23+ /// Only the <em>directory</em> part of a path is link resolved. The file name is kept verbatim, so two
24+ /// different names in one directory never collapse into one even when one of them is a link to the
25+ /// other. Resolving the leaf as well would let a stale alias masquerade as a live output and survive
26+ /// cleanup forever.
2527 /// </para>
2628 /// <para>
27- /// Canonicalization resolves the links of each path segment that already exists and appends the
28- /// segments that do not, so paths for files which have not been created yet can be canonicalized
29- /// without the failure a plain <c>realpath</c> would produce.
29+ /// Directories that do not exist yet are appended unresolved, so a path for a file the build has not
30+ /// written can be canonicalized without the failure a plain <c>realpath</c> would produce.
31+ /// </para>
32+ /// <para>
33+ /// The canonical form is only ever used for comparison. Callers keep the original item spec so the
34+ /// paths surfaced to the rest of the build stay in the spelling the user provided.
3035 /// </para>
3136 /// </remarks>
3237 internal sealed class PathCanonicalizer
@@ -44,114 +49,144 @@ internal sealed class PathCanonicalizer
4449 ? StringComparer . Ordinal
4550 : StringComparer . OrdinalIgnoreCase ;
4651
52+ /// <summary>The <see cref="StringComparison"/> matching <see cref="Comparer"/>.</summary>
53+ public static StringComparison Comparison { get ; } =
54+ RuntimeInformation . IsOSPlatform ( OSPlatform . Linux )
55+ ? StringComparison . Ordinal
56+ : StringComparison . OrdinalIgnoreCase ;
57+
4758 static readonly MethodInfo ResolveDirectoryLinkTarget =
4859 typeof ( Directory ) . GetMethod ( "ResolveLinkTarget" , BindingFlags . Public | BindingFlags . Static , null , new [ ] { typeof ( string ) , typeof ( bool ) } , null ) ;
4960
50- static readonly MethodInfo ResolveFileLinkTarget =
51- typeof ( File ) . GetMethod ( "ResolveLinkTarget" , BindingFlags . Public | BindingFlags . Static , null , new [ ] { typeof ( string ) , typeof ( bool ) } , null ) ;
52-
53- readonly Dictionary < string , string > directoryCache = new Dictionary < string , string > ( Comparer ) ;
61+ // Cache keys are exact spellings. Two directories whose names differ only by case can be two
62+ // different directories on a case sensitive volume, so a case insensitive key could hand back
63+ // another directory's resolved target.
64+ readonly Dictionary < string , string > directoryCache = new Dictionary < string , string > ( StringComparer . Ordinal ) ;
5465
5566 /// <summary>
56- /// Returns a canonical spelling of <paramref name="path"/>, or the input unchanged when it
57- /// cannot be canonicalized. Never throws .
67+ /// Returns the key to compare <paramref name="path"/> by: its link resolved directory plus its
68+ /// file name unchanged. Returns <see langword="null"/> when the path cannot be interpreted .
5869 /// </summary>
59- public string Canonicalize ( string path )
70+ public string GetComparisonKey ( string path )
6071 {
6172 if ( string . IsNullOrWhiteSpace ( path ) )
62- return path ;
73+ return null ;
6374
6475 string full ;
6576 try
6677 {
67- full = Path . GetFullPath ( path ) ;
78+ full = TrimTrailingSeparators ( Path . GetFullPath ( path ) ) ;
6879 }
6980 catch ( Exception )
7081 {
71- // An item spec can contain characters that are not valid in a path; leave it alone .
72- return path ;
82+ // An item spec can contain characters that are not valid in a path.
83+ return null ;
7384 }
7485
75- return CanonicalizeFullPath ( TrimTrailingSeparators ( full ) , MaxLinkHops ) ;
86+ var parent = Path . GetDirectoryName ( full ) ;
87+ var name = Path . GetFileName ( full ) ;
88+
89+ // A bare root such as "/" or "C:\" has no file name to keep.
90+ if ( string . IsNullOrEmpty ( parent ) || string . IsNullOrEmpty ( name ) )
91+ return CanonicalizeDirectory ( full ) ;
92+
93+ var directory = CanonicalizeDirectory ( parent ) ;
94+
95+ return directory is null ? null : Path . Combine ( directory , name ) ;
7696 }
7797
7898 /// <summary>
79- /// Builds a set of canonical paths that can be probed with <see cref="Canonicalize"/> results.
99+ /// Returns <paramref name="directory"/> with every link in it resolved, or <see langword="null"/>
100+ /// when it cannot be interpreted. Segments that do not exist are kept as they are.
80101 /// </summary>
81- public HashSet < string > CreateSet ( IEnumerable < string > paths )
102+ public string CanonicalizeDirectory ( string directory )
82103 {
83- var set = new HashSet < string > ( Comparer ) ;
104+ if ( string . IsNullOrWhiteSpace ( directory ) )
105+ return null ;
84106
85- if ( paths is not null )
107+ string full ;
108+ try
109+ {
110+ full = TrimTrailingSeparators ( Path . GetFullPath ( directory ) ) ;
111+ }
112+ catch ( Exception )
86113 {
87- foreach ( var path in paths )
88- {
89- if ( ! string . IsNullOrWhiteSpace ( path ) )
90- set . Add ( Canonicalize ( path ) ) ;
91- }
114+ return null ;
92115 }
93116
94- return set ;
117+ return Canonicalize ( full , MaxLinkHops ) ;
95118 }
96119
97- string CanonicalizeFullPath ( string full , int hops )
120+ /// <summary>
121+ /// Returns whether <paramref name="key"/> names something inside <paramref name="root"/>. Both
122+ /// must already be comparison keys.
123+ /// </summary>
124+ public static bool IsUnder ( string key , string root )
98125 {
99- var parent = Path . GetDirectoryName ( full ) ;
100- var name = Path . GetFileName ( full ) ;
126+ if ( string . IsNullOrEmpty ( key ) || string . IsNullOrEmpty ( root ) )
127+ return false ;
101128
102- // A root such as "/" or "C:\" has nothing left to resolve.
103- if ( string . IsNullOrEmpty ( parent ) || string . IsNullOrEmpty ( name ) )
104- return full ;
129+ if ( Comparer . Equals ( key , root ) )
130+ return true ;
131+
132+ if ( key . Length <= root . Length || ! key . StartsWith ( root , Comparison ) )
133+ return false ;
105134
106- return ResolveLink ( Path . Combine ( CanonicalizeDirectory ( parent , hops ) , name ) , hops ) ;
135+ // A root that already ends in a separator, such as "/" or "C:\", has no separator to skip.
136+ var last = root [ root . Length - 1 ] ;
137+ if ( last == Path . DirectorySeparatorChar || last == Path . AltDirectorySeparatorChar )
138+ return true ;
139+
140+ // Guard against "…/r-backup" being treated as living inside "…/r".
141+ var next = key [ root . Length ] ;
142+ return next == Path . DirectorySeparatorChar || next == Path . AltDirectorySeparatorChar ;
107143 }
108144
109- string CanonicalizeDirectory ( string directory , int hops )
145+ string Canonicalize ( string full , int hops )
110146 {
111- directory = TrimTrailingSeparators ( directory ) ;
112-
113- if ( directoryCache . TryGetValue ( directory , out var cached ) )
147+ if ( directoryCache . TryGetValue ( full , out var cached ) )
114148 return cached ;
115149
116- var canonical = CanonicalizeFullPath ( directory , hops ) ;
117- directoryCache [ directory ] = canonical ;
150+ var parent = Path . GetDirectoryName ( full ) ;
151+ var name = Path . GetFileName ( full ) ;
152+
153+ // A root resolves to itself, which also terminates the walk.
154+ var canonical = string . IsNullOrEmpty ( parent ) || string . IsNullOrEmpty ( name )
155+ ? full
156+ : ResolveDirectoryLink ( Path . Combine ( Canonicalize ( parent , hops ) , name ) , hops ) ;
157+
158+ directoryCache [ full ] = canonical ;
118159 return canonical ;
119160 }
120161
121- string ResolveLink ( string path , int hops )
162+ string ResolveDirectoryLink ( string path , int hops )
122163 {
123164 if ( hops <= 0 )
124165 return path ;
125166
126- var target = GetLinkTarget ( path ) ;
167+ var target = GetDirectoryLinkTarget ( path ) ;
127168 if ( target is null )
128169 return path ;
129170
130171 var resolved = TrimTrailingSeparators ( target . FullName ) ;
131172 if ( Comparer . Equals ( resolved , path ) )
132173 return path ;
133174
134- // The target itself may live under directories that are links, so canonicalize it too .
135- return CanonicalizeFullPath ( resolved , hops - 1 ) ;
175+ // The target itself may live under directories that are links.
176+ return Canonicalize ( resolved , hops - 1 ) ;
136177 }
137178
138- static FileSystemInfo GetLinkTarget ( string path )
179+ static FileSystemInfo GetDirectoryLinkTarget ( string path )
139180 {
140- // Directory/File .ResolveLinkTarget only exist on .NET 6 and later. This assembly targets
181+ // Directory.ResolveLinkTarget only exists on .NET 6 and later. This assembly targets
141182 // netstandard2.0 so that it can also load into MSBuild.exe on .NET Framework, where link
142- // resolution is simply unavailable and comparison falls back to the lexical full path.
143- var resolve = Directory . Exists ( path )
144- ? ResolveDirectoryLinkTarget
145- : File . Exists ( path )
146- ? ResolveFileLinkTarget
147- : null ;
148-
149- if ( resolve is null )
183+ // resolution is unavailable and comparison falls back to the lexical full path.
184+ if ( ResolveDirectoryLinkTarget is null || ! Directory . Exists ( path ) )
150185 return null ;
151186
152187 try
153188 {
154- return resolve . Invoke ( null , new object [ ] { path , /* returnFinalTarget: */ true } ) as FileSystemInfo ;
189+ return ResolveDirectoryLinkTarget . Invoke ( null , new object [ ] { path , /* returnFinalTarget: */ true } ) as FileSystemInfo ;
155190 }
156191 catch ( Exception )
157192 {
0 commit comments