File: UnsupportedProjectDataMarker.cs
Web Access
Project: Microsoft.NET.ProjectData.csproj (Microsoft.NET.ProjectData)
// 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 System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;

namespace Microsoft.NET.ProjectData;

/// <summary>
/// Reads and writes the user-cache sidecar that records projects known not to produce ProjectData.
/// </summary>
public static class UnsupportedProjectDataMarker
{
	public const int SchemaVersion = 1;
	public const int RulesVersion = 2;

	private const string MarkerExtension = ".unsupported";
	private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
	private static readonly string[] AncestorInputFileNames =
	[
		"Directory.Build.props",
		"Directory.Build.targets",
		"Directory.Packages.props",
		"global.json",
	];

	public static string GetMarkerFilePath(string projectFilePath) => UserFolderCachePath.Compute(projectFilePath) + MarkerExtension;

	public static bool TryReadValid(
		string projectFilePath,
		CancellationToken cancellationToken,
		out UnsupportedProjectDataMarkerData marker)
	{
		marker = new UnsupportedProjectDataMarkerData();
		cancellationToken.ThrowIfCancellationRequested();
		try
		{
			string markerPath = GetMarkerFilePath(projectFilePath);
			if (!File.Exists(markerPath))
			{
				return false;
			}

			Dictionary<string, string> values = ReadValues(markerPath, cancellationToken);
			if (!TryGetInt(values, "version", out int version) || version != SchemaVersion)
			{
				return false;
			}

			if (!TryGetInt(values, "rulesVersion", out int rulesVersion) || rulesVersion != RulesVersion)
			{
				return false;
			}

			if (!values.TryGetValue("project", out string? markerProjectPath) ||
				!PathsEqual(markerProjectPath, Path.GetFullPath(projectFilePath)))
			{
				return false;
			}

			string projectFingerprint = ComputeProjectFingerprint(projectFilePath, cancellationToken);
			if (!values.TryGetValue("projectFingerprint", out string? markerProjectFingerprint) ||
				!string.Equals(markerProjectFingerprint, projectFingerprint, StringComparison.Ordinal))
			{
				return false;
			}

			string inputsFingerprint = ComputeAncestorInputsFingerprintCore(projectFilePath, cancellationToken);
			if (!values.TryGetValue("inputsFingerprint", out string? markerInputsFingerprint) ||
				!string.Equals(markerInputsFingerprint, inputsFingerprint, StringComparison.Ordinal))
			{
				return false;
			}

			marker = new UnsupportedProjectDataMarkerData
			{
				ProjectFilePath = markerProjectPath,
				Reason = values.TryGetValue("reason", out string? reason) ? reason : string.Empty,
				MarkerFilePath = markerPath,
				ProjectFingerprint = projectFingerprint,
				InputsFingerprint = inputsFingerprint,
			};
			cancellationToken.ThrowIfCancellationRequested();
			return true;
		}
		catch (Exception ex) when (IsRecoverableMarkerException(ex))
		{
			cancellationToken.ThrowIfCancellationRequested();
			System.Diagnostics.Trace.TraceWarning(
				"[lscache] Failed to read unsupported-project marker for {0}: {1}",
				projectFilePath,
				ex.Message);
			return false;
		}
	}

	public static string Write(string projectFilePath, string reason)
	{
		if (string.IsNullOrWhiteSpace(reason))
		{
			reason = "Unsupported";
		}

		string markerPath = GetMarkerFilePath(projectFilePath);
		string? directory = Path.GetDirectoryName(markerPath);
		if (!string.IsNullOrEmpty(directory))
		{
			Directory.CreateDirectory(directory);
		}

		string content =
			$"version={SchemaVersion}\n" +
			$"rulesVersion={RulesVersion}\n" +
			$"project={Path.GetFullPath(projectFilePath)}\n" +
			$"projectFingerprint={ComputeProjectFingerprint(projectFilePath, CancellationToken.None)}\n" +
			$"inputsFingerprint={ComputeAncestorInputsFingerprintCore(projectFilePath, CancellationToken.None)}\n" +
			$"reason={reason}\n";

		WriteAllTextAtomically(markerPath, content);
		return markerPath;
	}

	public static void Delete(string projectFilePath)
	{
		try
		{
			string markerPath = GetMarkerFilePath(projectFilePath);
			File.Delete(markerPath);
		}
		catch (Exception ex) when (IsRecoverableMarkerException(ex))
		{
			System.Diagnostics.Trace.TraceWarning(
				"[lscache] Failed to delete unsupported-project marker for {0}: {1}",
				projectFilePath,
				ex.Message);
		}
	}

	public static string ComputeProjectFingerprint(string projectFilePath, CancellationToken cancellationToken)
		=> ComputeFileFingerprint(Path.GetFullPath(projectFilePath), cancellationToken);

	public static string ComputeAncestorInputsFingerprint(string projectFilePath, CancellationToken cancellationToken)
		=> ComputeAncestorInputsFingerprintCore(projectFilePath, cancellationToken);

	private static string ComputeAncestorInputsFingerprintCore(string projectFilePath, CancellationToken cancellationToken)
	{
		string? directory = Path.GetDirectoryName(Path.GetFullPath(projectFilePath));
		List<string> inputs = [];
		while (!string.IsNullOrEmpty(directory))
		{
			cancellationToken.ThrowIfCancellationRequested();
			foreach (string fileName in AncestorInputFileNames)
			{
				string candidate = Path.Combine(directory, fileName);
				if (File.Exists(candidate))
				{
					inputs.Add(Path.GetFullPath(candidate));
				}
			}

			DirectoryInfo? parent = Directory.GetParent(directory);
			if (parent is null || string.Equals(parent.FullName, directory, StringComparison.Ordinal))
			{
				break;
			}

			directory = parent.FullName;
		}

		inputs.Sort(PathComparer);
		StringBuilder builder = new();
		foreach (string input in inputs)
		{
			cancellationToken.ThrowIfCancellationRequested();
			builder.AppendLine(NormalizePathForFingerprint(input));
			builder.AppendLine(ComputeFileFingerprint(input, cancellationToken));
		}

		cancellationToken.ThrowIfCancellationRequested();
		return ComputeStringFingerprint(builder.ToString());
	}

	private static Dictionary<string, string> ReadValues(string markerPath, CancellationToken cancellationToken)
	{
		Dictionary<string, string> values = new(StringComparer.Ordinal);
		foreach (string line in File.ReadAllLines(markerPath))
		{
			cancellationToken.ThrowIfCancellationRequested();
			int separatorIndex = line.IndexOf('=');
			if (separatorIndex <= 0)
			{
				continue;
			}

			values[line.Substring(0, separatorIndex)] = line.Substring(separatorIndex + 1);
		}

		return values;
	}

	private static bool TryGetInt(Dictionary<string, string> values, string key, out int value)
	{
		value = 0;
		return values.TryGetValue(key, out string? text) && int.TryParse(text, out value);
	}

	private static string ComputeFileFingerprint(string path, CancellationToken cancellationToken)
	{
		cancellationToken.ThrowIfCancellationRequested();
		using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
		using SHA256 sha256 = SHA256.Create();
		byte[] hash = sha256.ComputeHash(stream);
		cancellationToken.ThrowIfCancellationRequested();
		return HexEncoder.ToLowerHex(hash);
	}

	private static string ComputeStringFingerprint(string value)
	{
		using SHA256 sha256 = SHA256.Create();
		byte[] hash = sha256.ComputeHash(Utf8NoBom.GetBytes(value));
		return HexEncoder.ToLowerHex(hash);
	}

	private static void WriteAllTextAtomically(string path, string content)
	{
		string tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
		try
		{
			File.WriteAllText(tempPath, content, Utf8NoBom);
			if (File.Exists(path))
			{
				File.Replace(tempPath, path, destinationBackupFileName: null);
			}
			else
			{
				File.Move(tempPath, path);
			}
		}
		finally
		{
			try
			{
				File.Delete(tempPath);
			}
			catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
			{
			}
		}
	}

	private static bool PathsEqual(string left, string right)
		=> string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), IsCaseSensitiveFileSystem() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);

	private static bool IsRecoverableMarkerException(Exception ex)
		=> ex is IOException or UnauthorizedAccessException or FormatException or ArgumentException or NotSupportedException or InvalidOperationException or CryptographicException;

	private static string NormalizePathForFingerprint(string path)
	{
		string normalized = Path.GetFullPath(path);
		return IsCaseSensitiveFileSystem()
			? normalized
			: normalized.ToLowerInvariant();
	}

	private static int PathComparer(string left, string right)
		=> string.Compare(left, right, IsCaseSensitiveFileSystem() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);

	private static bool IsCaseSensitiveFileSystem() => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
}

public sealed class UnsupportedProjectDataMarkerData
{
	public string ProjectFilePath { get; set; } = string.Empty;
	public string Reason { get; set; } = string.Empty;
	public string MarkerFilePath { get; set; } = string.Empty;
	public string ProjectFingerprint { get; set; } = string.Empty;
	public string InputsFingerprint { get; set; } = string.Empty;
}