// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
#if !WINDOWS_UWP && !WIN_UI
namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices;
///
/// Per-test temporary directory state and creation for .
///
internal sealed partial class TestContextImplementation
{
///
/// Maximum length (in characters) of the readable, sanitized test-name portion of the
/// per-test temporary directory name. This is a cap: the actual budget is computed
/// adaptively from how much room the base path leaves (see ),
/// and is only allowed to grow up to this cap.
///
private const int TestTempDirectoryNameMaxLength = 50;
///
/// Minimum readable-name budget worth keeping. If the base path is so deep that the adaptive
/// budget for the readable portion would drop below this floor, the implementation falls back to
/// the system temporary directory (which is short) rather than emit a barely-readable name that
/// still risks overflowing MAX_PATH.
///
private const int TestTempDirectoryNameMinLength = 8;
private const uint TestTempDirectoryUnixCreateMode = 0x1C0;
[DllImport("libc", EntryPoint = "mkdir", SetLastError = true)]
private static extern int MkDir([In] byte[] path, uint mode);
[DllImport("libc", EntryPoint = "chmod", SetLastError = true)]
private static extern int ChMod([In] byte[] path, uint mode);
///
/// Guards lazy creation of .
///
#if NET9_0_OR_GREATER
private readonly Lock _testTempDirectoryLock = new();
#else
private readonly object _testTempDirectoryLock = new();
#endif
///
/// Whether this context represents an executing test (as opposed to an assembly/class
/// initialize or cleanup fixture context). is a *per-test*
/// scratch directory; fixture contexts are not per-test and are not always disposed (e.g.
/// ClassCleanupManager.ForceCleanup contexts), so creating a directory for them would
/// leak it. The getter returns when this is .
///
private bool _isTestExecutionContext;
///
/// The lazily-created per-test temporary directory, or if it has not
/// been accessed (and therefore not created) yet.
///
private string? _testTempDirectory;
///
/// Whether has been created.
///
private bool _testTempDirectoryCreated;
///
/// Whether the test registered (via ) a result file that lives inside
/// the per-test temporary directory. Such a file is reported to the host as a result attachment
/// and is collected after this context is disposed, so the directory must be retained even on a
/// passing outcome. This flag is set eagerly in because the framework
/// consumes the result-file list during execution, before cleanup runs.
///
private bool _hasResultFileUnderTestTempDirectory;
///
/// Whether cleanup of the per-test temporary directory has started (i.e. the context is being
/// or has been disposed). Once set, the getter must not create a new directory, otherwise a
/// late access from a background thread the test spawned could create a directory *after*
/// cleanup already ran, leaking it. Guarded by .
///
private bool _testTempDirectoryCleanupStarted;
///
public override string? TestTempDirectory
{
get
{
// A per-test scratch directory only makes sense for an executing test. Fixture
// (assembly/class initialize and cleanup) contexts are not per-test and may never be
// disposed, so creating a directory for them would leak it — return null instead.
if (!_isTestExecutionContext)
{
return null;
}
if (Volatile.Read(ref _testTempDirectoryCreated))
{
return _testTempDirectory;
}
lock (_testTempDirectoryLock)
{
if (!_testTempDirectoryCreated)
{
if (_testTempDirectoryCleanupStarted)
{
// The context is being (or has been) disposed. Creating a directory now
// would leak it, because cleanup has already inspected the state. Treat a
// post-cleanup access as a no-op and return null (the test has finished).
return null;
}
_testTempDirectory = CreateTestTempDirectory();
Volatile.Write(ref _testTempDirectoryCreated, true);
}
}
return _testTempDirectory;
}
}
///
/// Creates the per-test temporary directory. The directory lives under the run's results
/// directory (so it is discoverable next to other run output); when no results directory is
/// configured this is the test assembly's output directory. On Windows the readable-name budget
/// is sized adaptively from how much room the base path leaves under MAX_PATH, and — when
/// the results directory is so deep that even a minimal readable name cannot preserve the
/// reserved headroom for the test's own files — the implementation falls back to the short
/// system temporary directory instead. It also falls back to the system temporary directory when
/// the chosen base directory cannot be written to (for example a read-only output directory), so
/// the property returns a usable path rather than throwing from the getter.
///
private string CreateTestTempDirectory()
{
string? resultsDirectory = TestResultsDirectory is { Length: > 0 } results ? results : null;
string baseDirectory = resultsDirectory ?? Path.GetTempPath();
bool baseIsTemp = resultsDirectory is null;
// Size the readable-name budget so that base + '\' + name + '_' + suffix, plus the reserved
// headroom for the files the test writes inside, stays within MAX_PATH on Windows. On other
// operating systems path length is effectively a non-issue (per-component limit is 255 and
// our whole segment is well under that), so the readable name simply gets the full cap.
int nameBudget = TestTempDirectoryNameMaxLength;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
int available = ComputeReadableNameBudget(baseDirectory);
if (available < TestTempDirectoryNameMinLength && !baseIsTemp)
{
// The results directory is too deep to leave usable headroom; fall back to the
// short system temp directory so the test still gets room to write.
baseDirectory = Path.GetTempPath();
baseIsTemp = true;
available = ComputeReadableNameBudget(baseDirectory);
}
nameBudget = available < 0 ? 0 : Math.Min(available, TestTempDirectoryNameMaxLength);
}
if (TryCreateTestTempDirectoryUnder(baseDirectory, nameBudget, out string created))
{
return created;
}
// The chosen base directory could not be written to (e.g. a read-only output directory).
// Fall back to the system temporary directory, which is writable, so the property still
// returns a usable path instead of throwing from the getter.
if (!baseIsTemp)
{
string tempBase = Path.GetTempPath();
int tempBudget = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Math.Max(0, Math.Min(ComputeReadableNameBudget(tempBase), TestTempDirectoryNameMaxLength))
: TestTempDirectoryNameMaxLength;
if (TryCreateTestTempDirectoryUnder(tempBase, tempBudget, out created))
{
return created;
}
}
// Could not create anywhere with a readable name; make one last attempt under the system
// temp directory and let any exception surface as a genuine error.
string fallback = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
CreateDirectoryWithRestrictedPermissions(fallback);
return fallback;
}
///
/// Attempts to create a uniquely-named per-test temporary directory under .
/// Returns (rather than throwing) when the base directory cannot be
/// written to, so the caller can fall back to another location.
///
private bool TryCreateTestTempDirectoryUnder(string baseDirectory, int nameBudget, out string createdPath)
{
string namePart = SanitizeTestTempDirectoryName(GetTestTempDirectoryNameSource(), nameBudget);
try
{
Directory.CreateDirectory(baseDirectory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
createdPath = string.Empty;
return false;
}
// The suffix is a full 128-bit GUID, so two contexts choosing the same directory name is
// cryptographically negligible. Exists + CreateDirectory is not an atomic exclusive create,
// so the retry loop below is a belt-and-braces guard rather than a real necessity.
const int maxAttempts = 10;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
string suffix = Guid.NewGuid().ToString("N").Substring(0, TestTempDirectoryUniqueSuffixLength);
string candidateName = namePart.Length == 0 ? suffix : $"{namePart}_{suffix}";
string candidate = Path.Combine(baseDirectory, candidateName);
if (Directory.Exists(candidate))
{
continue;
}
try
{
CreateDirectoryWithRestrictedPermissions(candidate);
createdPath = candidate;
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// The base directory is not writable — a read-only output directory, or (on .NET
// Framework) a denied filesystem permission surfaced as SecurityException. Signal
// failure so the caller can fall back to the system temporary directory. Retrying a
// different name under the same base would not help, so bail out immediately.
createdPath = string.Empty;
return false;
}
}
createdPath = string.Empty;
return false;
}
private static void CreateDirectoryWithRestrictedPermissions(string path)
{
#if NETCOREAPP
if (OperatingSystem.IsBrowser() || OperatingSystem.IsWasi())
{
Directory.CreateDirectory(path);
return;
}
#endif
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
byte[] nullTerminatedUtf8Path = System.Text.Encoding.UTF8.GetBytes(path + "\0");
int result = MkDir(nullTerminatedUtf8Path, TestTempDirectoryUnixCreateMode);
if (result != 0)
{
int error = Marshal.GetLastWin32Error();
throw new IOException($"Could not create test temporary directory '{path}'.", new System.ComponentModel.Win32Exception(error));
}
result = ChMod(nullTerminatedUtf8Path, TestTempDirectoryUnixCreateMode);
if (result != 0)
{
int error = Marshal.GetLastWin32Error();
var permissionException = new System.ComponentModel.Win32Exception(error);
try
{
Directory.Delete(path);
}
catch (Exception cleanupException) when (cleanupException is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
throw new IOException(
$"Could not set permissions on or remove test temporary directory '{path}'.",
new AggregateException(permissionException, cleanupException));
}
throw new IOException($"Could not set permissions on test temporary directory '{path}'.", permissionException);
}
return;
}
Directory.CreateDirectory(path);
}
}
#endif