Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -7,5 +7,6 @@ static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectory
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func<string!, string!>! sanitizeLeafFileName) -> string!
Microsoft.Testing.Extensions.MergeOutputFileHelper
static Microsoft.Testing.Extensions.MergeOutputFileHelper.BuildCaseFoldedProbePath(string! directory, string! probeFileName) -> string!
static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList<string!>! inputPaths, string! outputPath) -> void
static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func<string!, System.Threading.Tasks.Task!>! writeToTempAsync) -> System.Threading.Tasks.Task!
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectory
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func<string!, string!>! sanitizeLeafFileName) -> string!
Microsoft.Testing.Extensions.MergeOutputFileHelper
static Microsoft.Testing.Extensions.MergeOutputFileHelper.BuildCaseFoldedProbePath(string! directory, string! probeFileName) -> string!
static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList<string!>! inputPaths, string! outputPath) -> void
static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func<string!, System.Threading.Tasks.Task!>! writeToTempAsync) -> System.Threading.Tasks.Task!
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayloa
const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Expected = 10 -> ushort
const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Actual = 11 -> ushort
Microsoft.Testing.Extensions.MergeOutputFileHelper
static Microsoft.Testing.Extensions.MergeOutputFileHelper.BuildCaseFoldedProbePath(string! directory, string! probeFileName) -> string!
static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList<string!>! inputPaths, string! outputPath) -> void
static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func<string!, System.Threading.Tasks.Task!>! writeToTempAsync) -> System.Threading.Tasks.Task!
17 changes: 14 additions & 3 deletions src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,11 @@ private static bool IsDirectoryCaseSensitive(string directory)
{
try
{
string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N"));
using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose))
string probeName = "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N");
string probePath = Path.Combine(directory, probeName);
using (new FileStream(probePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose))
{
return !File.Exists(upper.ToLowerInvariant());
return !File.Exists(BuildCaseFoldedProbePath(directory, probeName));
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
Expand All @@ -186,6 +187,16 @@ private static bool IsDirectoryCaseSensitive(string directory)
}
}

// Builds the case-folded candidate used to detect case sensitivity: ONLY the generated probe file name
// is lower-cased, while the real 'directory' path is preserved verbatim. Lower-casing the whole combined
// path (the previous bug) corrupts the directory portion, so a case-insensitive child directory sitting
// beneath a case-sensitive, differently-cased ancestor (e.g. an ext4 casefold dir named with uppercase
// chars) would be probed at a non-existent lowercased ancestor: File.Exists returns false and the
// location is misreported as case-sensitive. Kept as its own seam so this behaviour can be unit-tested
// without needing to materialize a mixed-sensitivity filesystem.
internal static string BuildCaseFoldedProbePath(string directory, string probeFileName)
=> Path.Combine(directory, probeFileName.ToLowerInvariant());

private static string GetTempSiblingPath(string outputPath)
{
string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Reflection;
using System.Text.Json.Nodes;

using Microsoft.Testing.Extensions.CtrfReport;
Expand Down Expand Up @@ -220,6 +221,108 @@ await Assert.ThrowsExactlyAsync<ArgumentException>(
}
}

[TestMethod]
public void BuildCaseFoldedProbePath_LowerCasesOnlyFileName_PreservingCaseSensitiveDirectory()
{
// Regression seam for the case-sensitivity probe. The probe compares the created file against a
// case-folded candidate to decide whether the directory is case-sensitive. The bug lower-cased the
// WHOLE combined path, corrupting the directory portion: a case-insensitive child directory beneath a
// case-sensitive, differently-cased ancestor was then probed at a non-existent lowercased ancestor, so
// File.Exists returned false and the directory was misreported as case-sensitive. That in turn made
// EnsureOutputDoesNotAliasInput compare paths ordinally and miss that 'a.trx' and 'A.trx' are the same
// file, risking overwrite of a read-only input. Assert directly that only the generated file name is
// case-folded while the (potentially case-sensitive, uppercased) directory path is preserved verbatim.
// Reverting the production fix to lower-case the whole path fails this test on every platform.
// The helper is an internal type linked into several extension assemblies, so it is reached via the
// unambiguous CtrfReport assembly (a simple-name reference would be ambiguous across those copies).
MethodInfo buildProbe = typeof(CtrfReportMerger).Assembly
.GetType("Microsoft.Testing.Extensions.MergeOutputFileHelper", throwOnError: true)!
.GetMethod("BuildCaseFoldedProbePath", BindingFlags.Static | BindingFlags.NonPublic)!;

string directory = Path.Combine("SomeCaseSensitive", "PARENT", "Child");
const string probeFileName = "CASESENSITIVEPROBEabc123";

string candidate = (string)buildProbe.Invoke(null, [directory, probeFileName])!;

Assert.AreEqual(Path.Combine(directory, "casesensitiveprobeabc123"), candidate);
Assert.AreEqual(directory, Path.GetDirectoryName(candidate));
Assert.AreEqual("casesensitiveprobeabc123", Path.GetFileName(candidate));
}

[TestMethod]
public async Task MergeToFileAsync_WhenOutputAliasesInputByCaseOnly_IsRejectedOnCaseInsensitiveFilesystem()
{
// End-to-end sibling of the seam test above, scoped to a single scenario: on a case-insensitive
// directory (Windows/macOS temp dirs are), an output that differs from an input only by CASE aliases
// that input and must be rejected so a read-only source is never overwritten. Skipped on a genuinely
// case-sensitive host, where the two names are distinct (that scenario is covered by the sibling test).
string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDirectory);
Comment thread
Evangelink marked this conversation as resolved.
try
{
if (!IsDirectoryCaseInsensitive(tempDirectory))
{
Assert.Inconclusive("Host temp filesystem is case-sensitive; covered by the case-sensitive sibling test.");
}

string input = Path.Combine(tempDirectory, "report.json");
File.WriteAllText(input, BuildReport());

string casedOutput = Path.Combine(tempDirectory, "REPORT.json");
await Assert.ThrowsExactlyAsync<ArgumentException>(
() => CtrfReportMerger.MergeToFileAsync([input], casedOutput, CancellationToken.None));
Assert.IsTrue(File.Exists(input));
}
finally
{
Directory.Delete(tempDirectory, recursive: true);
}
}

[TestMethod]
public async Task MergeToFileAsync_WhenOutputDiffersByCaseOnly_IsAllowedOnCaseSensitiveFilesystem()
{
// Complementary scenario to the case-insensitive test above: on a genuinely case-sensitive directory,
// an output that differs from an input only by CASE is a distinct file, so the merge is allowed and the
// input is preserved. Skipped on a case-insensitive host (that scenario is the sibling test).
string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDirectory);
try
{
if (IsDirectoryCaseInsensitive(tempDirectory))
{
Assert.Inconclusive("Host temp filesystem is case-insensitive; covered by the case-insensitive sibling test.");
}

string input = Path.Combine(tempDirectory, "report.json");
File.WriteAllText(input, BuildReport());

string casedOutput = Path.Combine(tempDirectory, "REPORT.json");
await CtrfReportMerger.MergeToFileAsync([input], casedOutput, CancellationToken.None);
Assert.IsTrue(File.Exists(input));
Assert.IsTrue(File.Exists(casedOutput));
}
finally
{
Directory.Delete(tempDirectory, recursive: true);
}
}

private static bool IsDirectoryCaseInsensitive(string directory)
{
string probe = Path.Combine(directory, "CaseProbe" + Guid.NewGuid().ToString("N"));
File.WriteAllText(probe, string.Empty);
try
{
// Only the file name is lower-cased so the (possibly case-sensitive) directory path stays intact.
return File.Exists(Path.Combine(directory, Path.GetFileName(probe).ToLowerInvariant()));
}
finally
{
File.Delete(probe);
}
}

[TestMethod]
public async Task MergeToFileAsync_WritesMergedFileToDisk()
{
Expand Down
Loading