Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ file static partial class DirectoryInfoAssertionExtensions
public static bool IsHidden(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.Hidden) == true;
[GenerateAssertion(ExpectationMessage = "to not be hidden", InlineMethodBody = true)]
public static bool IsNotHidden(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.Hidden) == false;
/// <remarks>The System attribute only exists on Windows; on Linux and macOS this never holds.</remarks>
[GenerateAssertion(ExpectationMessage = "to be a system directory", InlineMethodBody = true)]
public static bool IsSystemDirectory(this DirectoryInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ file static partial class FileInfoAssertionExtensions
public static bool IsEmpty(this FileInfo value) => value?.Length == 0;
[GenerateAssertion(ExpectationMessage = "to not be empty", InlineMethodBody = true)]
public static bool IsNotEmpty(this FileInfo value) => value != null && value.Length > 0;
/// <remarks>The System attribute only exists on Windows; on Linux and macOS this never holds.</remarks>
[GenerateAssertion(ExpectationMessage = "to be a system file", InlineMethodBody = true)]
public static bool IsSystemFile(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.System) == true;
/// <remarks>The Archive attribute is only maintained on Windows; on Linux and macOS this never holds.</remarks>
[GenerateAssertion(ExpectationMessage = "to be archived", InlineMethodBody = true)]
public static bool IsArchived(this FileInfo value) => value?.Attributes.HasFlag(FileAttributes.Archive) == true;
}
28 changes: 25 additions & 3 deletions src/TUnit.Assertions/Conditions/FileSystemAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<Directory
}

// FileInfo assertions - unique ones that don't conflict with FileInfoAssertionExtensions
/// <remarks>
/// The System attribute only exists on Windows; on Linux and macOS this assertion always passes.
/// </remarks>
[AssertionExtension("IsNotSystem")]
public class FileIsNotSystemAssertion : Assertion<FileInfo>
{
Expand Down Expand Up @@ -132,6 +135,10 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<FileInfo>
protected override string GetExpectation() => "to not be a system file";
}

/// <remarks>
/// On Linux and macOS a file counts as executable when any execute permission bit is set. On Windows,
/// and on .NET Framework, it is judged by extension (.exe, .bat, .cmd, .com, .sh, .ps1).
/// </remarks>
[AssertionExtension("IsNotExecutable")]
public class FileIsNotExecutableAssertion : Assertion<FileInfo>
{
Expand Down Expand Up @@ -162,17 +169,32 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<FileInfo>
return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' does not exist"));
}

var executableExtensions = new[] { ".exe", ".bat", ".cmd", ".com", ".sh", ".ps1" };
var isExecutable = executableExtensions.Contains(value.Extension.ToLowerInvariant());
#if NET
// On Unix, executability is the file mode, not the name: a 0755 "myapp" is executable and
// a 0644 "notes.sh" is not.
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsBrowser())
{
const UnixFileMode anyExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;

if ((value.UnixFileMode & anyExecute) != 0)
{
return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (mode: {value.UnixFileMode})"));
}

if (isExecutable)
return AssertionResult._passedTask;
}
#endif

if (ExecutableExtensions.Contains(value.Extension, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(AssertionResult.Failed($"file '{value.FullName}' is executable (extension: {value.Extension})"));
}

return AssertionResult._passedTask;
}

private static readonly string[] ExecutableExtensions = [".exe", ".bat", ".cmd", ".com", ".sh", ".ps1"];

protected override string GetExpectation() => "to not be executable";
}

Expand Down
3 changes: 1 addition & 2 deletions src/TUnit.Core/Attributes/TestMetadata/ExcludeOnAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ public override Task<bool> ShouldSkip(TestRegisteredContext context)
// Check if the current platform matches any of the excluded operating systems
var shouldSkip =
OperatingSystem.HasFlag(OS.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
#if NET
// Only validate Linux and macOS on .NET 5+ where these OS flags are available
|| (OperatingSystem.HasFlag(OS.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|| (OperatingSystem.HasFlag(OS.MacOs) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
#if NET
|| (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser())
#endif
;
Expand Down
6 changes: 4 additions & 2 deletions src/TUnit.Core/Attributes/TestMetadata/RunOnAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ public override Task<bool> ShouldSkip(TestRegisteredContext context)
{
return Task.FromResult(false);
}
#if NET
// Only validate Linux and macOS on .NET 5+ where these OS flags are available

// RuntimeInformation.IsOSPlatform is available on netstandard2.0 too, so .NET Framework
// test projects running under Mono on Linux or macOS must reach these checks.
if (OperatingSystem.HasFlag(OS.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return Task.FromResult(false);
Expand All @@ -65,6 +66,7 @@ public override Task<bool> ShouldSkip(TestRegisteredContext context)
return Task.FromResult(false);
}

#if NET
// Check for Browser platform (WebAssembly)
if (OperatingSystem.HasFlag(OS.Browser) && System.OperatingSystem.IsBrowser())
{
Expand Down
69 changes: 67 additions & 2 deletions src/TUnit.Core/EngineCancellationToken.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#if NET
using System.Runtime.InteropServices;
#endif
using System.Runtime.Versioning;
using TUnit.Core.Settings;

Expand All @@ -22,14 +25,29 @@ public class EngineCancellationToken : IDisposable
private volatile bool _forcefulExitStarted;
private CancellationTokenRegistration _platformRegistration;
private bool _cancelKeyPressSubscribed;
#if NET
private PosixSignalRegistration? _sigtermRegistration;
private volatile bool _terminationSignalReceived;
#endif

/// <summary>
/// Whether the run was cancelled by SIGTERM. Microsoft.Testing.Platform does not observe that
/// signal, so unlike Ctrl+C its own run token is not cancelled alongside ours.
/// </summary>
internal bool TerminationSignalReceived =>
#if NET
_terminationSignalReceived;
#else
false;
#endif

public EngineCancellationToken()
{
Token = CancellationTokenSource.Token;
}

/// <summary>
/// Hooks up process-wide cancellation signals (Ctrl+C / ProcessExit) the first time it's
/// Hooks up process-wide cancellation signals (Ctrl+C / SIGTERM / ProcessExit) the first time it's
/// called for this instance. Idempotent — subsequent calls are no-ops so that concurrent
/// MTP server-mode RPCs against one session don't clobber each other's cancellation chain.
/// Per-call cancellation flows through the explicit <c>CancellationToken</c> threaded into
Expand Down Expand Up @@ -67,8 +85,50 @@ internal void Initialise(CancellationToken platformCancellationToken = default)
#if NET5_0_OR_GREATER
}
#endif

#if NET
_sigtermRegistration = TryRegisterSigterm();
#endif
}

#if NET
/// <summary>
/// SIGTERM is how Unix asks a process to stop (<c>docker stop</c>, Kubernetes, CI cancellation,
/// <c>timeout</c>). Left to the runtime it only raises <see cref="AppDomain.ProcessExit"/>, which
/// gives After hooks <see cref="TimeoutSettings.ProcessExitHookDelay"/> before the process
/// dies. Handling it like Ctrl+C instead cancels the run and allows the full
/// <see cref="TimeoutSettings.ForcefulExitTimeout"/> for cleanup. Windows is left alone:
/// there the signal maps to console close/shutdown events, which the runtime already handles.
/// </summary>
private PosixSignalRegistration? TryRegisterSigterm()
{
// Android, iOS and tvOS do not deliver POSIX signals to managed code.
if (OperatingSystem.IsWindows() || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi()
|| OperatingSystem.IsAndroid() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS())
{
return null;
}

try
{
return PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSigterm);
}
catch (PlatformNotSupportedException)
{
return null;
}
}

private void OnSigterm(PosixSignalContext context)
{
// Prevent the default behaviour (immediate termination); the forceful-exit timer still
// guarantees the process ends.
context.Cancel = true;
_terminationSignalReceived = true;
Cancel();
Comment thread
Quattordici marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
#endif

/// <summary>
/// Subscribes to <see cref="Console.CancelKeyPress"/>. Platforms without console signals
/// (such as Android, iOS and tvOS) throw <see cref="PlatformNotSupportedException"/>; there the
Expand Down Expand Up @@ -144,7 +204,8 @@ private void Cancel(bool armForcefulExit = true)

private void OnProcessExit(object? sender, EventArgs e)
{
// Process is exiting (SIGTERM, kill, etc.) - trigger cancellation to execute After hooks
// Process is exiting (Environment.Exit, end of Main, or a signal not handled above) - trigger
// cancellation to execute After hooks.
// Note: ProcessExit runs on a background thread with limited time (~3 seconds on Windows)
// The After hooks registered via CancellationToken.Register() will execute when we cancel
if (!CancellationTokenSource.IsCancellationRequested)
Expand All @@ -165,6 +226,10 @@ private void OnProcessExit(object? sender, EventArgs e)
public void Dispose()
{
_platformRegistration.Dispose();
#if NET
_sigtermRegistration?.Dispose();
_sigtermRegistration = null;
#endif

// Console.CancelKeyPress is not supported on browser platforms
#if NET5_0_OR_GREATER
Expand Down
14 changes: 13 additions & 1 deletion src/TUnit.Core/Executors/STAThreadExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Runtime.Versioning;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using TUnit.Core.Exceptions;

namespace TUnit.Core;

Expand All @@ -7,6 +9,16 @@ public class STAThreadExecutor : DedicatedThreadExecutor
{
protected override void ConfigureThread(Thread thread)
{
// [SupportedOSPlatform] only warns at compile time; without this guard a test run on
// Linux or macOS gets a bare PlatformNotSupportedException that names neither the
// executor nor the fix.
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
throw new TUnitException(
$"{nameof(STAThreadExecutor)} requires Windows: STA apartment threads are not supported on {RuntimeInformation.OSDescription}. " +
"Restrict the test with [RunOn(OS.Windows)] or use a different executor on this platform.");
}

thread.SetApartmentState(ApartmentState.STA);
}
}
7 changes: 5 additions & 2 deletions src/TUnit.Engine/Exceptions/TUnitFailedException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ internal static string FilterStackTrace(string? stackTrace)
ReadOnlySpan<char> pendingSeparator = default;
var hasPendingSeparator = false;

foreach (var range in span.Split(Environment.NewLine))
// Split on '\n' and trim '\r' rather than splitting on Environment.NewLine: a trace captured
// on another OS (CRLF read on Unix, LF read on Windows) would otherwise be one long line and
// nothing would be filtered.
foreach (var range in span.Split('\n'))
{
var slice = span[range];
var slice = span[range].TrimEnd('\r');
var trimmed = slice.Trim();

if (IsTUnitInternalFrame(trimmed))
Expand Down
13 changes: 12 additions & 1 deletion src/TUnit.Engine/Framework/TUnitTestFramework.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,21 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
}
catch (Exception e) when (IsCancellationException(e))
{
var serviceProvider = GetOrCreateServiceProvider(context);

if (!context.CancellationToken.IsCancellationRequested && serviceProvider.CancellationToken.TerminationSignalReceived)
{
// MTP only treats the exception as a cancellation when its own token fired, which
// SIGTERM does not do; re-throwing would crash the host. Fail the session instead.
await serviceProvider.Logger.LogErrorAsync("The test run was cancelled by SIGTERM.");
serviceProvider.SessionFailed = true;
return;
}

var message = context.CancellationToken.IsCancellationRequested
? "The test run was cancelled."
: "Test execution stopped due to fail-fast.";
await GetOrCreateServiceProvider(context).Logger.LogErrorAsync(message);
await serviceProvider.Logger.LogErrorAsync(message);

// Re-throw is safe here — MTP handles OperationCanceledException specially.
throw;
Expand Down
39 changes: 39 additions & 0 deletions src/TUnit.Engine/Helpers/FileLockDetection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Runtime.InteropServices;

namespace TUnit.Engine.Helpers;

/// <summary>
/// Recognises the <see cref="IOException"/> thrown when another process holds a file, so report
/// writers can retry instead of giving up.
/// </summary>
internal static class FileLockDetection
{
// Windows: the low word of the HResult is the Win32 error.
private const int ErrorSharingViolation = 0x20;
private const int ErrorLockViolation = 0x21;

// Unix: .NET takes an advisory flock for FileShare and reports contention with the raw errno
// as the HResult. EWOULDBLOCK (== EAGAIN) differs between platforms.
private const int EWouldBlockLinux = 11;
private const int EWouldBlockMacOS = 35;

internal static bool IsFileLocked(IOException exception)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var errorCode = exception.HResult & 0xFFFF;

if (errorCode is ErrorSharingViolation or ErrorLockViolation)
{
return true;
}
}
else if (exception.HResult == (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? EWouldBlockMacOS : EWouldBlockLinux))
{
return true;
}

// Fallback heuristic for hosts that map the error differently.
return exception.Message.Contains("being used by another process");
}
}
27 changes: 26 additions & 1 deletion src/TUnit.Engine/Helpers/PathValidator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if NET8_0_OR_GREATER
using System.Buffers;
#endif
using System.Runtime.InteropServices;

namespace TUnit.Engine.Helpers;

Expand Down Expand Up @@ -92,7 +93,7 @@ internal static string ValidateAndNormalizePath(string? path, string parameterNa
{
var currentDir = Path.GetFullPath(Directory.GetCurrentDirectory());

if (!fullPath.StartsWith(currentDir, StringComparison.OrdinalIgnoreCase))
if (!IsWithinDirectory(fullPath, currentDir))
{
throw new ArgumentException(
$"Relative path resolves outside the current working directory and is not allowed: '{validatedPath}'",
Expand All @@ -103,6 +104,30 @@ internal static string ValidateAndNormalizePath(string? path, string parameterNa
return fullPath;
}

/// <summary>
/// Whether <paramref name="fullPath"/> is <paramref name="directory"/> itself or lies beneath it.
/// Requires a separator at the boundary, so <c>/foo/barbaz</c> is not inside <c>/foo/bar</c>, and
/// folds case only on Windows: on a case-sensitive filesystem <c>/foo/Bar</c> is a different directory.
/// </summary>
internal static bool IsWithinDirectory(string fullPath, string directory)
{
var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

// A root ("/" or "C:\") trims to "" or "C:", after which the boundary check still holds.
var root = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

if (!fullPath.StartsWith(root, comparison))
{
return false;
}

return fullPath.Length == root.Length
|| fullPath[root.Length] == Path.DirectorySeparatorChar
|| fullPath[root.Length] == Path.AltDirectorySeparatorChar;
}

private static bool ContainsPathTraversal(string path)
{
// Check for ".." segments which indicate path traversal
Expand Down
Loading
Loading