File: ProjectDataBuildReceipt.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 completion-only evidence for one ProjectDataBuild attempt.
/// </summary>
public static class ProjectDataBuildReceipt
{
	public const int SchemaVersion = 2;
	public const string AggregateCompletionFileName = "aggregate.completed";

	private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

	public static string GetReceiptFilePath(string receiptDirectory, string projectFilePath)
	{
		ThrowIfNullOrWhiteSpace(receiptDirectory, nameof(receiptDirectory));
		ThrowIfNullOrWhiteSpace(projectFilePath, nameof(projectFilePath));

		string normalizedProjectPath = NormalizeProjectPath(projectFilePath);
		using SHA256 sha256 = SHA256.Create();
		string fileName = HexEncoder.ToLowerHex(sha256.ComputeHash(Utf8NoBom.GetBytes(normalizedProjectPath))) + ".completed";
		return Path.Combine(receiptDirectory, fileName);
	}

	public static string GetAggregateCompletionFilePath(string receiptDirectory)
	{
		ThrowIfNullOrWhiteSpace(receiptDirectory, nameof(receiptDirectory));
		return Path.Combine(receiptDirectory, AggregateCompletionFileName);
	}

	public static string Write(string receiptDirectory, string attemptId, string projectFilePath)
	{
		ValidateInputs(receiptDirectory, attemptId);
		ThrowIfNullOrWhiteSpace(projectFilePath, nameof(projectFilePath));

		Directory.CreateDirectory(receiptDirectory);
		string fullProjectPath = Path.GetFullPath(projectFilePath);
		string receiptPath = GetReceiptFilePath(receiptDirectory, fullProjectPath);
		string content =
			$"version={SchemaVersion}\n" +
			$"attempt={attemptId}\n" +
			$"project={fullProjectPath}\n";
		WriteAllTextAtomically(receiptPath, content);
		return receiptPath;
	}

	public static bool TryRead(
		string receiptDirectory,
		string attemptId,
		string projectFilePath,
		out ProjectDataBuildReceiptData receipt)
	{
		receipt = new ProjectDataBuildReceiptData();
		try
		{
			ValidateInputs(receiptDirectory, attemptId);
			ThrowIfNullOrWhiteSpace(projectFilePath, nameof(projectFilePath));

			string receiptPath = GetReceiptFilePath(receiptDirectory, projectFilePath);
			if (!File.Exists(receiptPath))
			{
				return false;
			}

			Dictionary<string, string> values = ReadValues(receiptPath);
			if (values.Count != 3 ||
				!TryGetInt(values, "version", out int version) ||
				version != SchemaVersion ||
				!values.TryGetValue("attempt", out string? receiptAttemptId) ||
				!string.Equals(receiptAttemptId, attemptId, StringComparison.Ordinal) ||
				!values.TryGetValue("project", out string? receiptProjectPath) ||
				!PathsEqual(receiptProjectPath, projectFilePath))
			{
				return false;
			}

			receipt = new ProjectDataBuildReceiptData
			{
				AttemptId = receiptAttemptId,
				ProjectFilePath = receiptProjectPath,
				ReceiptFilePath = receiptPath,
			};
			return true;
		}
		catch (Exception ex) when (IsRecoverableEvidenceException(ex))
		{
			System.Diagnostics.Trace.TraceWarning(
				"[ProjectDataBuildReceipt] Failed to read completion receipt for {0}: {1}",
				projectFilePath,
				ex.Message);
			return false;
		}
	}

	public static void WriteAggregateCompletion(string receiptDirectory, string attemptId)
	{
		ValidateInputs(receiptDirectory, attemptId);
		Directory.CreateDirectory(receiptDirectory);
		string content =
			$"version={SchemaVersion}\n" +
			$"attempt={attemptId}\n";
		WriteAllTextAtomically(GetAggregateCompletionFilePath(receiptDirectory), content);
	}

	public static bool TryReadAggregateCompletion(string receiptDirectory, string attemptId)
	{
		try
		{
			ValidateInputs(receiptDirectory, attemptId);
			string receiptPath = GetAggregateCompletionFilePath(receiptDirectory);
			if (!File.Exists(receiptPath))
			{
				return false;
			}

			Dictionary<string, string> values = ReadValues(receiptPath);
			return values.Count == 2 &&
				TryGetInt(values, "version", out int version) &&
				version == SchemaVersion &&
				values.TryGetValue("attempt", out string? receiptAttemptId) &&
				string.Equals(receiptAttemptId, attemptId, StringComparison.Ordinal);
		}
		catch (Exception ex) when (IsRecoverableEvidenceException(ex))
		{
			System.Diagnostics.Trace.TraceWarning(
				"[ProjectDataBuildReceipt] Failed to read aggregate completion for attempt {0}: {1}",
				attemptId,
				ex.Message);
			return false;
		}
	}

	private static void ValidateInputs(string receiptDirectory, string attemptId)
	{
		ThrowIfNullOrWhiteSpace(receiptDirectory, nameof(receiptDirectory));
		ThrowIfNullOrWhiteSpace(attemptId, nameof(attemptId));
	}

	private static Dictionary<string, string> ReadValues(string path)
	{
		Dictionary<string, string> values = new(StringComparer.Ordinal);
		foreach (string line in File.ReadAllLines(path))
		{
			int separatorIndex = line.IndexOf('=');
			if (separatorIndex <= 0)
			{
				throw new FormatException($"Receipt '{path}' contains a malformed line.");
			}

			string key = line.Substring(0, separatorIndex);
			if (values.ContainsKey(key))
			{
				throw new FormatException($"Receipt '{path}' contains a duplicate '{key}' field.");
			}

			values.Add(key, 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 NormalizeProjectPath(string projectFilePath)
	{
		string fullPath = Path.GetFullPath(projectFilePath);
		return RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
			? fullPath
			: fullPath.ToUpperInvariant();
	}

	private static bool PathsEqual(string left, string right)
		=> string.Equals(
			Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
			Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
			RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);

	internal static void WriteAllTextAtomically(string path, string content)
	{
		string? directory = Path.GetDirectoryName(path);
		if (!string.IsNullOrEmpty(directory))
		{
			Directory.CreateDirectory(directory);
		}

		string tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
		try
		{
			File.WriteAllText(tempPath, content, Utf8NoBom);
			IOException? lastWriteError = null;
			for (int attempt = 0; attempt < 8; attempt++)
			{
				try
				{
					if (File.Exists(path))
					{
						if (string.Equals(File.ReadAllText(path, Utf8NoBom), content, StringComparison.Ordinal))
						{
							return;
						}

						File.Replace(tempPath, path, destinationBackupFileName: null);
					}
					else
					{
						File.Move(tempPath, path);
					}

					return;
				}
				catch (IOException ex)
				{
					lastWriteError = ex;
					Thread.Sleep(1);
				}
			}

			throw new IOException($"Failed to atomically write ProjectData build receipt '{path}' after concurrent write retries.", lastWriteError);
		}
		finally
		{
			try
			{
				File.Delete(tempPath);
			}
			catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
			{
			}
		}
	}

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

	private static void ThrowIfNullOrWhiteSpace(string value, string parameterName)
	{
		if (string.IsNullOrWhiteSpace(value))
		{
			throw new ArgumentException("Value cannot be null or whitespace.", parameterName);
		}
	}
}

public sealed class ProjectDataBuildReceiptData
{
	public string AttemptId { get; set; } = string.Empty;
	public string ProjectFilePath { get; set; } = string.Empty;
	public string ReceiptFilePath { get; set; } = string.Empty;
}