// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.NET.ProjectData;
namespace Microsoft.NET.ProjectData.Tasks;
/// <summary>
/// Substitutes well-known absolute paths with sentinel tokens
/// (<c><NUGET></c>, <c><DOTNET></c>, <c><NETFXREF></c>) so that
/// emitted project data is portable across machines. Used both for full path
/// sections (<see cref="ToPortable"/>) and for absolute paths embedded inside
/// property values or command-line arguments (<see cref="MakePortable"/>).
/// </summary>
internal sealed class CachePathResolver
{
private readonly string[] nugetFolders;
private readonly string[] dotnetRoots;
private readonly string? netFxRefRoot;
private readonly string projectDir;
private readonly StringComparison pathsComparison;
public CachePathResolver(string projectFilePath)
{
this.projectDir = Path.GetDirectoryName(projectFilePath) ?? string.Empty;
bool caseInsensitive = Path.DirectorySeparatorChar == '\\';
this.pathsComparison = caseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
this.nugetFolders = ResolveNuGetFolders();
this.dotnetRoots = ResolveDotNetRoots();
this.netFxRefRoot = ResolveNetFxRefRoot();
}
// For testing — allows injecting roots directly.
internal CachePathResolver(string projectDir, string[] nugetFolders, string[] dotnetRoots, string? netFxRefRoot)
{
this.projectDir = projectDir;
bool caseInsensitive = Path.DirectorySeparatorChar == '\\';
this.pathsComparison = caseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
this.nugetFolders = nugetFolders;
this.dotnetRoots = dotnetRoots;
this.netFxRefRoot = netFxRefRoot;
}
internal string ProjectDirectory => this.projectDir;
// Converts a full or project-relative file path to a portable <SENTINEL>/...
// form when it falls under one of the well-known roots, or to a project-relative
// path otherwise.
public string ToPortable(string inputPath)
{
if (string.IsNullOrEmpty(inputPath)) return inputPath;
string absolutePath = this.ToAbsolutePath(inputPath);
for (int i = 0; i < this.nugetFolders.Length; i++)
{
if (absolutePath.StartsWith(this.nugetFolders[i], this.pathsComparison))
return PathSentinels.Nuget + "/" + absolutePath.Substring(this.nugetFolders[i].Length).Replace('\\', '/');
}
for (int i = 0; i < this.dotnetRoots.Length; i++)
{
if (absolutePath.StartsWith(this.dotnetRoots[i], this.pathsComparison))
{
string portable = PathSentinels.Dotnet + "/" + absolutePath.Substring(this.dotnetRoots[i].Length).Replace('\\', '/');
return RewriteSdkPath(portable);
}
}
if (this.netFxRefRoot != null && absolutePath.StartsWith(this.netFxRefRoot, this.pathsComparison))
return PathSentinels.NetFxRef + "/" + absolutePath.Substring(this.netFxRefRoot.Length).Replace('\\', '/');
string relative = MakeRelative(this.projectDir, absolutePath).Replace('\\', '/');
return TryRewriteAsNuGetPp(relative) ?? relative;
}
internal string ToAbsolutePath(string inputPath)
{
if (string.IsNullOrEmpty(inputPath)) return inputPath;
return Path.IsPathRooted(inputPath)
? Path.GetFullPath(inputPath)
: Path.GetFullPath(Path.Combine(this.projectDir, inputPath));
}
/// <summary>
/// Detects project-relative paths produced by the SDK's NuGet content-asset preprocessor
/// (format: <c>obj/{Config}/{TFM}/NuGet/{XxHash3-16hex}/{PackageId}/{Version}/...</c>)
/// and rewrites them to the <c><NUGETPP>/{PackageId}/{Version}/...</c> sentinel form.
/// This makes the cache fully portable: the obj-relative prefix and the environment-dependent
/// hash are both removed. At read time, the reader resolves <c><NUGETPP></c> by scanning
/// for the actual hash directory under the project's intermediate output path.
/// </summary>
internal static string? TryRewriteAsNuGetPp(string relativePath)
{
// Pattern: .../NuGet/<16-hex-chars>/<PackageId>/<Version>/...
// We look for "/NuGet/" followed by exactly 16 hex characters and then "/"
const string NuGetSegment = "/NuGet/";
int nugetIdx = relativePath.IndexOf(NuGetSegment, StringComparison.OrdinalIgnoreCase);
if (nugetIdx < 0) return null;
int hashStart = nugetIdx + NuGetSegment.Length;
// Must have at least 16 chars + trailing '/'
if (hashStart + 17 > relativePath.Length) return null;
if (relativePath[hashStart + 16] != '/') return null;
// Validate all 16 characters are hex digits
for (int i = 0; i < 16; i++)
{
char c = relativePath[hashStart + i];
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')))
return null;
}
// Everything after the hash+separator is "<PackageId>/<Version>/..."
string packageRelativePath = relativePath.Substring(hashStart + 17);
return PathSentinels.NugetPp + "/" + packageRelativePath;
}
// <DOTNET>/sdk/<version>/Sdks/Microsoft.NET.Sdk/analyzers/Foo.dll
// → <NETSDK>/Sdks/Microsoft.NET.Sdk/analyzers/Foo.dll
//
// The version segment is dropped because:
// - the contents of <DOTNET>/sdk/<version>/ are otherwise identical across SDK
// patch releases (analyzer DLLs, global configs);
// - keeping the version in the cache would invalidate every project's lscache
// on every SDK patch with no behavioral benefit.
//
// The reader's CachePathResolver requires a caller-supplied SDK binding to
// resolve <NETSDK>; the cache file itself is intentionally environment-agnostic.
internal static string RewriteSdkPath(string portable)
{
const string SdkPrefix = PathSentinels.Dotnet + "/sdk/";
if (!portable.StartsWith(SdkPrefix, StringComparison.Ordinal)) return portable;
int versionEnd = portable.IndexOf('/', SdkPrefix.Length);
if (versionEnd < 0) return portable;
return PathSentinels.NetSdk + portable.Substring(versionEnd);
}
// Finds an absolute path embedded anywhere inside a property value or
// command-line argument and replaces the first match with its sentinel form,
// preserving any surrounding text.
public string MakePortable(string text)
{
if (string.IsNullOrEmpty(text)) return text;
for (int i = 0; i < this.nugetFolders.Length; i++)
{
int idx = text.IndexOf(this.nugetFolders[i], this.pathsComparison);
if (idx >= 0)
return FormatEmbedded(text, idx, PathSentinels.Nuget, idx + this.nugetFolders[i].Length);
}
for (int i = 0; i < this.dotnetRoots.Length; i++)
{
int idx = text.IndexOf(this.dotnetRoots[i], this.pathsComparison);
if (idx >= 0)
{
string portable = FormatEmbedded(text, idx, PathSentinels.Dotnet, idx + this.dotnetRoots[i].Length);
return RewriteEmbeddedSdkPath(portable, idx);
}
}
if (this.netFxRefRoot != null)
{
int idx = text.IndexOf(this.netFxRefRoot, this.pathsComparison);
if (idx >= 0)
return FormatEmbedded(text, idx, PathSentinels.NetFxRef, idx + this.netFxRefRoot.Length);
}
int pathStart = FindAbsolutePathStart(text);
if (pathStart >= 0)
{
string prefix = text.Substring(0, pathStart);
string absolutePath = text.Substring(pathStart);
string relativePath = MakeRelative(this.projectDir, absolutePath).Replace('\\', '/');
return prefix + PathSentinels.Path + relativePath;
}
return text.Replace('\\', '/');
}
private static string FormatEmbedded(string source, int prefixLength, string sentinel, int suffixStart)
{
string prefix = source.Substring(0, prefixLength);
string suffix = source.Substring(suffixStart).Replace('\\', '/');
return prefix + sentinel + "/" + suffix;
}
// Same rewrite rule as RewriteSdkPath but for embedded matches: scans the
// result for "<prefix><DOTNET>/sdk/<ver>/" and rewrites to "<prefix><NETSDK>/".
// sentinelStart marks where the original prefix ended; we look just after
// that for the embedded "<DOTNET>" marker so we don't accidentally rewrite
// unrelated text earlier in the string.
internal static string RewriteEmbeddedSdkPath(string portable, int sentinelStart)
{
const string SdkSegment = PathSentinels.Dotnet + "/sdk/";
int idx = portable.IndexOf(SdkSegment, sentinelStart, StringComparison.Ordinal);
if (idx < 0) return portable;
int versionEnd = portable.IndexOf('/', idx + SdkSegment.Length);
if (versionEnd < 0) return portable;
return portable.Substring(0, idx) + PathSentinels.NetSdk + portable.Substring(versionEnd);
}
// Finds the start index of an absolute path embedded in text.
// Windows: looks for `[A-Za-z]:\` or `[A-Za-z]:/` not preceded by a letter-or-digit.
// Unix: looks for `/<letter>...` at start or after `:`, `"`, or ` `.
internal static int FindAbsolutePathStart(string text)
{
for (int i = 0; i <= text.Length - 3; i++)
{
if (char.IsLetter(text[i]) && text[i + 1] == ':' && (text[i + 2] == '\\' || text[i + 2] == '/'))
{
if (i == 0 || !char.IsLetterOrDigit(text[i - 1]))
return i;
}
}
if (Path.DirectorySeparatorChar != '\\')
{
for (int i = 0; i < text.Length - 1; i++)
{
if (text[i] == '/' && char.IsLetter(text[i + 1]))
{
if (i == 0 || text[i - 1] == ':' || text[i - 1] == '"' || text[i - 1] == ' ')
{
int nextSlash = text.IndexOf('/', i + 1);
if (nextSlash > i + 1)
{
int colon = text.IndexOf(':', i + 1);
if (colon >= 0 && colon < nextSlash) continue;
return i;
}
}
}
}
}
return -1;
}
// Returns the longest common directory prefix (ending with '/') of two forward-slash paths,
// or null if there is no common directory prefix.
internal static string? FindSharedDirPrefix(string a, string b)
{
int minLen = Math.Min(a.Length, b.Length);
int lastSlash = -1;
for (int i = 0; i < minLen; i++)
{
if (char.ToUpperInvariant(a[i]) != char.ToUpperInvariant(b[i]))
break;
if (a[i] == '/')
lastSlash = i;
}
return lastSlash < 0 ? null : a.Substring(0, lastSlash + 1);
}
// netstandard2.0-compatible MakeRelative. Both paths must be absolute.
// Output is '/'-normalized by callers.
internal static string MakeRelative(string basePath, string fullPath)
{
if (string.IsNullOrEmpty(basePath)) return fullPath;
string normalizedBase = basePath.Replace('\\', '/');
if (!normalizedBase.EndsWith("/")) normalizedBase += "/";
string normalizedFull = fullPath.Replace('\\', '/');
bool ci = Path.DirectorySeparatorChar == '\\';
StringComparison cmp = ci ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
string[] baseSegs = normalizedBase.TrimEnd('/').Split('/');
string[] fullSegs = normalizedFull.Split('/');
int common = 0;
int limit = Math.Min(baseSegs.Length, fullSegs.Length);
while (common < limit && string.Equals(baseSegs[common], fullSegs[common], cmp))
common++;
var rel = new System.Text.StringBuilder();
for (int i = common; i < baseSegs.Length; i++)
{
if (rel.Length > 0) rel.Append('/');
rel.Append("..");
}
for (int i = common; i < fullSegs.Length; i++)
{
if (rel.Length > 0) rel.Append('/');
rel.Append(fullSegs[i]);
}
return rel.Length == 0 ? "." : rel.ToString();
}
private static string[] ResolveNuGetFolders()
{
string? envVal = Environment.GetEnvironmentVariable("NUGET_PACKAGES");
if (!string.IsNullOrWhiteSpace(envVal))
return [NormalizeFolderPath(envVal)];
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(userProfile))
return [NormalizeFolderPath(Path.Combine(userProfile, ".nuget", "packages"))];
return [];
}
private static string[] ResolveDotNetRoots()
{
var list = new List<string>();
void Add(string? candidate)
{
if (string.IsNullOrEmpty(candidate) || !Directory.Exists(candidate)) return;
string norm = NormalizeFolderPath(candidate!);
foreach (var existing in list)
if (string.Equals(existing, norm, StringComparison.OrdinalIgnoreCase)) return;
list.Add(norm);
}
Add(Environment.GetEnvironmentVariable("DOTNET_ROOT"));
Add(TryGetDotNetRootFromHostPath(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH")));
Add(TryGetDotNetRootFromSdkPath(AppContext.BaseDirectory));
if (Path.DirectorySeparatorChar == '\\')
{
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrEmpty(programFiles)) Add(Path.Combine(programFiles, "dotnet"));
}
else
{
Add("/usr/share/dotnet");
Add("/usr/local/share/dotnet");
}
string userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(userHome)) userHome = Environment.GetEnvironmentVariable("HOME") ?? string.Empty;
if (!string.IsNullOrEmpty(userHome)) Add(Path.Combine(userHome, ".dotnet"));
return list.ToArray();
}
internal static string? TryGetDotNetRootFromHostPath(string? hostPath)
{
if (string.IsNullOrWhiteSpace(hostPath) || !Path.IsPathRooted(hostPath)) return null;
return Path.GetDirectoryName(Path.GetFullPath(hostPath));
}
internal static string? TryGetDotNetRootFromSdkPath(string? sdkPath)
{
if (string.IsNullOrWhiteSpace(sdkPath)) return null;
DirectoryInfo sdkVersionDirectory = new DirectoryInfo(Path.GetFullPath(sdkPath));
DirectoryInfo? sdkDirectory = sdkVersionDirectory.Parent;
if (sdkDirectory is null || !string.Equals(sdkDirectory.Name, "sdk", StringComparison.OrdinalIgnoreCase)) return null;
return sdkDirectory.Parent?.FullName;
}
private static string? ResolveNetFxRefRoot()
{
if (Path.DirectorySeparatorChar != '\\') return null;
string pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
if (string.IsNullOrEmpty(pfx86)) return null;
string candidate = Path.Combine(pfx86, "Reference Assemblies", "Microsoft", "Framework", ".NETFramework");
return Directory.Exists(candidate) ? NormalizeFolderPath(candidate) : null;
}
internal static string NormalizeFolderPath(string path)
{
string full = Path.GetFullPath(path);
if (full.Length == 0 || full[full.Length - 1] != Path.DirectorySeparatorChar)
full += Path.DirectorySeparatorChar;
return full;
}
}