File: Commands\Test\MTP\TestResultsDirectoryResolver.cs
Web Access
Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;

namespace Microsoft.DotNet.Cli.Commands.Test;

/// <summary>
/// Computes the results directory handed to each test application.
/// The per-module layout mirrors the SDK artifacts output layout
/// (https://learn.microsoft.com/dotnet/core/sdk/artifacts-output): a project folder containing a
/// pivot folder, where pivot elements are joined by an underscore.
/// <para>
/// Project names are not guaranteed to be unique within a run, so the whole module set is inspected
/// up front. Only when two distinct projects would land in the same project folder is a short
/// identity hash appended to disambiguate them, keeping the common case clean.
/// </para>
/// </summary>
internal sealed class TestResultsDirectoryResolver
{
    private const string DefaultResultsDirectoryName = "TestResults";
    private const string UnknownComponent = "unknown";
    private const int MaxPathComponentLength = 255;

    private static readonly HashSet<char> s_invalidPathComponentCharacters =
        [.. Path.GetInvalidFileNameChars(), Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];

    private readonly PathOptions _pathOptions;
    private readonly string _workingDirectory;
    private readonly string _identityRoot;
    private readonly HashSet<string> _ambiguousProjectNames;

    private TestResultsDirectoryResolver(PathOptions pathOptions, string workingDirectory, string identityRoot, HashSet<string> ambiguousProjectNames)
    {
        _pathOptions = pathOptions;
        _workingDirectory = workingDirectory;
        _identityRoot = identityRoot;
        _ambiguousProjectNames = ambiguousProjectNames;
    }

    public static TestResultsDirectoryResolver Create(PathOptions pathOptions, IEnumerable<TestModule> modules, string workingDirectory)
    {
        if (pathOptions.ResultsDirectoryLayout == ResultsDirectoryLayout.Flat)
        {
            return new TestResultsDirectoryResolver(pathOptions, workingDirectory, workingDirectory, []);
        }
        // Anchor identities to the directory shared by every module rather than the current
        // directory, so the same solution produces the same folder names no matter where
        // 'dotnet test' was invoked from.
        List<TestModule> materializedModules = [.. modules];
        string identityRoot = GetCommonRootDirectory(materializedModules, workingDirectory);

        Dictionary<string, HashSet<string>> identitiesByProjectName = new(StringComparer.OrdinalIgnoreCase);
        foreach (TestModule module in materializedModules)
        {
            string projectName = GetProjectName(module);
            if (!identitiesByProjectName.TryGetValue(projectName, out HashSet<string>? identities))
            {
                identities = new HashSet<string>(StringComparer.Ordinal);
                identitiesByProjectName.Add(projectName, identities);
            }

            identities.Add(GetProjectIdentity(module, identityRoot));
        }

        HashSet<string> ambiguousProjectNames = new(StringComparer.OrdinalIgnoreCase);
        foreach ((string projectName, HashSet<string> identities) in identitiesByProjectName)
        {
            if (identities.Count > 1)
            {
                ambiguousProjectNames.Add(projectName);
            }
        }

        return new TestResultsDirectoryResolver(pathOptions, workingDirectory, identityRoot, ambiguousProjectNames);
    }

    /// <summary>
    /// A resolver that always yields the configured results directory, whatever the requested
    /// layout. Used by internal invocations such as artifact post-processing, which merge results
    /// across modules and so must not be scoped to a single module's directory.
    /// </summary>
    public static TestResultsDirectoryResolver CreateShared(PathOptions pathOptions, string workingDirectory)
        => new(pathOptions with { ResultsDirectoryLayout = ResultsDirectoryLayout.Flat }, workingDirectory, workingDirectory, []);

    public string? Resolve(TestModule module)
    {
        if (_pathOptions.ResultsDirectoryLayout == ResultsDirectoryLayout.Flat)
        {
            return _pathOptions.ResultsDirectoryPath;
        }

        string resultsDirectory = _pathOptions.ResultsDirectoryPath
            ?? Path.Combine(_workingDirectory, DefaultResultsDirectoryName);

        string resolved = Path.GetFullPath(
            Path.Combine(resultsDirectory, GetProjectDirectoryName(module), GetPivotDirectoryName(module)));

        // Sanitization strips separators and dot-only components, so a module can never steer its
        // results out of the requested root. Asserted rather than thrown because it is unreachable
        // by design and only a future change to the component rules could break it.
        Debug.Assert(IsUnderRoot(resolved, resultsDirectory), $"'{resolved}' escaped the results directory '{resultsDirectory}'.");

        return resolved;
    }

    private static bool IsUnderRoot(string candidate, string root)
    {
        string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
        string relative = Path.GetRelativePath(normalizedRoot, candidate);

        return relative != ".."
            && !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)
            && !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
            && !Path.IsPathRooted(relative);
    }

    /// <summary>
    /// The deepest directory that contains every module, used as a stable anchor for identities.
    /// Falls back to the working directory when the modules share nothing (for example, modules on
    /// different drives).
    /// </summary>
    private static string GetCommonRootDirectory(List<TestModule> modules, string workingDirectory)
    {
        string? commonRoot = null;
        foreach (TestModule module in modules)
        {
            string? moduleDirectory = Path.GetDirectoryName(GetProjectPath(module, workingDirectory));
            if (string.IsNullOrEmpty(moduleDirectory))
            {
                continue;
            }

            commonRoot = commonRoot is null ? moduleDirectory : GetCommonPrefixDirectory(commonRoot, moduleDirectory);
            if (string.IsNullOrEmpty(commonRoot))
            {
                return workingDirectory;
            }
        }

        return string.IsNullOrEmpty(commonRoot) ? workingDirectory : commonRoot;
    }

    private static string GetCommonPrefixDirectory(string first, string second)
    {
        StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

        // Keep the filesystem root ('C:\', '/', '\\server\share\') attached. Joining bare segments
        // would turn 'C:\foo' and 'C:\bar' into the drive-relative 'C:', whose meaning depends on
        // the process working directory.
        string firstRoot = Path.GetPathRoot(first) ?? string.Empty;
        string secondRoot = Path.GetPathRoot(second) ?? string.Empty;
        if (firstRoot.Length == 0 || !string.Equals(firstRoot, secondRoot, comparison))
        {
            return string.Empty;
        }

        char[] separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
        string[] firstSegments = first[firstRoot.Length..].Split(separators, StringSplitOptions.RemoveEmptyEntries);
        string[] secondSegments = second[secondRoot.Length..].Split(separators, StringSplitOptions.RemoveEmptyEntries);

        int shared = 0;
        while (shared < firstSegments.Length
            && shared < secondSegments.Length
            && string.Equals(firstSegments[shared], secondSegments[shared], comparison))
        {
            shared++;
        }

        return Path.Combine(firstRoot, string.Join(Path.DirectorySeparatorChar, firstSegments, 0, shared));
    }

    /// <summary>
    /// The project folder, defaulting to the project file name and falling back to the assembly name
    /// when the module was discovered through <c>--test-modules</c> instead of a project. A short hash
    /// is appended only when another distinct project in the same run shares the name.
    /// </summary>
    private string GetProjectDirectoryName(TestModule module)
    {
        string projectName = GetProjectName(module);

        return LimitComponentLength(_ambiguousProjectNames.Contains(projectName)
            ? $"{projectName}_{GetShortHash(GetProjectIdentity(module, _identityRoot))}"
            : projectName);
    }

    /// <summary>
    /// The pivot folder distinguishing runs of the same project across target frameworks and
    /// runtimes. Multiple elements are joined by an underscore, following the artifacts layout.
    /// The configuration is deliberately not part of the pivot: a single test run targets one
    /// configuration, so it would only ever add a constant level to every path.
    /// </summary>
    private static string GetPivotDirectoryName(TestModule module)
    {
        string targetFramework = SanitizePathComponent(module.TargetFramework);
        string runtime = SanitizePathComponent(GetRuntimeComponent(module));

        return LimitComponentLength($"{targetFramework}_{runtime}".ToLowerInvariant());
    }

    /// <summary>
    /// Prefers the runtime identifier the module was actually built for, so that runs differing
    /// only by RID stay separate, and falls back to the architecture for the common case where no
    /// runtime identifier was requested.
    /// </summary>
    private static string GetRuntimeComponent(TestModule module)
    {
        if (!string.IsNullOrEmpty(module.RunProperties.RuntimeIdentifier))
        {
            return module.RunProperties.RuntimeIdentifier;
        }

        return GetTargetArchitecture(module).ToString();
    }

    private static string GetProjectName(TestModule module)
    {
        string? projectName = string.IsNullOrEmpty(module.ProjectFullPath)
            ? Path.GetFileNameWithoutExtension(module.TargetPath)
            : Path.GetFileNameWithoutExtension(module.ProjectFullPath);

        return SanitizePathComponent(projectName);
    }

    /// <summary>
    /// Identifies the project a module belongs to. Modules of a multi-targeted project share an
    /// identity so they nest under a single project folder and are separated only by their pivot.
    /// </summary>
    private static string GetProjectIdentity(TestModule module, string identityRoot)
    {
        string path = GetProjectPath(module, identityRoot);
        if (string.IsNullOrEmpty(path))
        {
            return string.Empty;
        }

        string relativePath = Path.GetRelativePath(identityRoot, path)
            .Replace(Path.DirectorySeparatorChar, '/');

        return OperatingSystem.IsWindows() ? relativePath.ToLowerInvariant() : relativePath;
    }

    private static string GetProjectPath(TestModule module, string basePath)
    {
        string path = string.IsNullOrEmpty(module.ProjectFullPath) ? module.TargetPath : module.ProjectFullPath;

        return string.IsNullOrEmpty(path) ? string.Empty : Path.GetFullPath(path, basePath);
    }

    private static string GetShortHash(string value)
    {
        byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
        return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
    }

    private static Architecture GetTargetArchitecture(TestModule module)
    {
        if (EnvironmentVariableNames.TryParseArchitecture(module.RunProperties.RuntimeIdentifier, out Architecture architecture)
            || EnvironmentVariableNames.TryParseArchitecture(module.RunProperties.DefaultAppHostRuntimeIdentifier, out architecture))
        {
            return architecture;
        }

        return RuntimeInformation.ProcessArchitecture;
    }

    private static string SanitizePathComponent(string? value)
    {
        if (string.IsNullOrEmpty(value))
        {
            return UnknownComponent;
        }

        StringBuilder builder = new(value.Length);
        foreach (char character in value)
        {
            builder.Append(s_invalidPathComponentCharacters.Contains(character) ? '_' : character);
        }

        string sanitized = builder.ToString();

        // A project named '...csproj' yields '..', which would otherwise walk out of the results
        // directory. Trailing dots and spaces are also not addressable on Windows.
        string trimmed = sanitized.TrimEnd('.', ' ');

        return trimmed.Length == 0 ? UnknownComponent : trimmed;
    }

    /// <summary>
    /// Keeps a single directory component within the limit common to Windows and Linux
    /// filesystems, so that a long project name (or a long name plus its disambiguating suffix)
    /// cannot make the test application fail to create its results directory.
    /// </summary>
    private static string LimitComponentLength(string component)
    {
        if (component.Length <= MaxPathComponentLength)
        {
            return component;
        }

        // The appended hash is computed over the full component, so truncated names stay unique.
        string hash = GetShortHash(component);
        return string.Concat(component.AsSpan(0, MaxPathComponentLength - hash.Length - 1), "_", hash);
    }
}