File: DiskBased\PathValidationTests.cs
Project: ..\..\..\test\Libraries\Microsoft.Extensions.AI.Evaluation.Reporting.Tests\Microsoft.Extensions.AI.Evaluation.Reporting.Tests.csproj (Microsoft.Extensions.AI.Evaluation.Reporting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Evaluation.Reporting.Utilities;
using Xunit;
 
namespace Microsoft.Extensions.AI.Evaluation.Reporting.Tests;
 
public class PathValidationTests
{
    // ──────────────────────────────────────────────
    //  ValidatePathSegment – valid inputs
    // ──────────────────────────────────────────────
 
    [Fact]
    public void ValidatePathSegment_Null_DoesNotThrow()
    {
        PathValidation.ValidatePathSegment(null, "param");
    }
 
    [Theory]
    [InlineData("simple")]
    [InlineData("My Scenario")]
    [InlineData("run-2024-01-01")]
    [InlineData("iteration_0")]
    [InlineData("a")]
    [InlineData("...")]
    [InlineData("..x")]
    [InlineData("x..")]
    public void ValidatePathSegment_ValidNames_DoesNotThrow(string segment)
    {
        PathValidation.ValidatePathSegment(segment, "param");
    }
 
    // ──────────────────────────────────────────────
    //  ValidatePathSegment – invalid inputs
    // ──────────────────────────────────────────────
 
    [Fact]
    public void ValidatePathSegment_EmptyString_Throws()
    {
        Assert.Throws<ArgumentException>(() =>
            PathValidation.ValidatePathSegment("", "param"));
    }
 
    [Theory]
    [InlineData("..")]
    [InlineData(".")]
    public void ValidatePathSegment_TraversalLiterals_Throws(string segment)
    {
        Assert.Throws<ArgumentException>(() =>
            PathValidation.ValidatePathSegment(segment, "param"));
    }
 
    [Theory]
    [InlineData("foo/bar")]
    [InlineData("../secret")]
    public void ValidatePathSegment_ContainsForwardSlash_Throws(string segment)
    {
        Assert.Throws<ArgumentException>(() =>
            PathValidation.ValidatePathSegment(segment, "param"));
    }
 
    [Theory]
    [InlineData("foo\\bar")]
    [InlineData("..\\secret")]
    public void ValidatePathSegment_ContainsBackslash_ThrowsOnWindows(string segment)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // Backslash is a path separator (and invalid filename char) on Windows.
            Assert.Throws<ArgumentException>(() =>
                PathValidation.ValidatePathSegment(segment, "param"));
        }
        else
        {
            // Backslash is a valid filename character on Linux/macOS.
            PathValidation.ValidatePathSegment(segment, "param");
        }
    }
 
    [Theory]
    [InlineData(" leading")]
    [InlineData("trailing ")]
    [InlineData(" both ")]
    public void ValidatePathSegment_WhitespacePadded_Throws(string segment)
    {
        Assert.Throws<ArgumentException>(() =>
            PathValidation.ValidatePathSegment(segment, "param"));
    }
 
    [Fact]
    public void ValidatePathSegment_NullCharacter_Throws()
    {
        Assert.Throws<ArgumentException>(() =>
            PathValidation.ValidatePathSegment("foo\0bar", "param"));
    }
 
    // ──────────────────────────────────────────────
    //  EnsureWithinRoot – paths inside root
    // ──────────────────────────────────────────────
 
    [Fact]
    public void EnsureWithinRoot_ChildPath_ReturnsResolvedPath()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot");
        string child = Path.Combine(root, "sub", "file.txt");
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    [Fact]
    public void EnsureWithinRoot_DeeplyNested_ReturnsResolvedPath()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot");
        string child = Path.Combine(root, "a", "b", "c", "d.json");
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    [Fact]
    public void EnsureWithinRoot_RootWithTrailingSeparator_Works()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot") + Path.DirectorySeparatorChar;
        string child = Path.Combine(root, "file.txt");
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    // ──────────────────────────────────────────────
    //  EnsureWithinRoot – paths escaping root
    // ──────────────────────────────────────────────
 
    [Fact]
    public void EnsureWithinRoot_DotDotEscapes_Throws()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot");
        string escaped = Path.Combine(root, "..", "outside");
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, escaped));
    }
 
    [Fact]
    public void EnsureWithinRoot_MultipleDotDots_Throws()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot", "nested");
        string escaped = Path.Combine(root, "..", "..", "outside");
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, escaped));
    }
 
    [Fact]
    public void EnsureWithinRoot_CompletelyDifferentPath_Throws()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot");
        string other = Path.Combine(Path.GetTempPath(), "other", "file.txt");
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, other));
    }
 
    [Fact]
    public void EnsureWithinRoot_SiblingWithPrefix_Throws()
    {
        // Verifies that "testroot-sibling" is NOT treated as being inside "testroot".
        string root = Path.Combine(Path.GetTempPath(), "testroot");
        string sibling = Path.Combine(Path.GetTempPath(), "testroot-sibling", "file.txt");
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, sibling));
    }
 
    [Fact]
    public void EnsureWithinRoot_PathEqualsRoot_DoesNotThrow()
    {
        string root = Path.Combine(Path.GetTempPath(), "testroot");
 
        string result = PathValidation.EnsureWithinRoot(root, root);
 
        Assert.Equal(Path.GetFullPath(root), result);
    }
 
    // ──────────────────────────────────────────────
    //  Integration: DiskBasedResultStore rejects traversal
    // ──────────────────────────────────────────────
 
    [Fact]
    public async Task DiskBasedResultStore_DeleteWithTraversal_Throws()
    {
        string storagePath = Path.Combine(Path.GetTempPath(), "M.E.AI.Eval.PathTests", Path.GetRandomFileName());
 
        try
        {
            Directory.CreateDirectory(storagePath);
            var store = new Storage.DiskBasedResultStore(storagePath);
 
            await Assert.ThrowsAsync<ArgumentException>(() =>
                store.DeleteResultsAsync(executionName: "..", scenarioName: "../sentinel").AsTask());
        }
        finally
        {
            try
            {
                Directory.Delete(storagePath, true);
            }
#pragma warning disable CA1031 // Do not catch general exception types.
            catch
#pragma warning restore CA1031
            {
                // Best effort cleanup.
            }
        }
    }
 
    [Fact]
    public async Task DiskBasedResponseCacheProvider_TraversalInScenarioName_Throws()
    {
        string storagePath = Path.Combine(Path.GetTempPath(), "M.E.AI.Eval.PathTests", Path.GetRandomFileName());
 
        try
        {
            Directory.CreateDirectory(storagePath);
            var provider = new Storage.DiskBasedResponseCacheProvider(storagePath);
 
            await Assert.ThrowsAsync<ArgumentException>(() =>
                provider.GetCacheAsync("..", "..").AsTask());
        }
        finally
        {
            try
            {
                Directory.Delete(storagePath, true);
            }
#pragma warning disable CA1031 // Do not catch general exception types.
            catch
#pragma warning restore CA1031
            {
                // Best effort cleanup.
            }
        }
    }
 
    // ──────────────────────────────────────────────
    //  EnsureWithinRoot – UNC paths (Windows only)
    // ──────────────────────────────────────────────
 
    [Fact]
    public void EnsureWithinRoot_UncPath_ChildPath_ReturnsResolved()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"\\server\share\data";
        string child = @"\\server\share\data\sub\file.txt";
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    [Fact]
    public void EnsureWithinRoot_UncPath_DifferentShare_Throws()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"\\server\share\data";
        string other = @"\\server\share\other\file.txt";
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, other));
    }
 
    [Fact]
    public void EnsureWithinRoot_UncPath_DotDotEscapes_Throws()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"\\server\share\data";
        string escaped = @"\\server\share\data\..\other";
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, escaped));
    }
 
    [Fact]
    public void EnsureWithinRoot_UncPath_SiblingWithPrefix_Throws()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"\\server\share\data";
        string sibling = @"\\server\share\data-sibling\file.txt";
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, sibling));
    }
 
    [Fact]
    public void EnsureWithinRoot_UncPath_PathEqualsRoot_DoesNotThrow()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"\\server\share\data";
 
        string result = PathValidation.EnsureWithinRoot(root, root);
 
        Assert.Equal(Path.GetFullPath(root), result);
    }
 
    // ──────────────────────────────────────────────
    //  EnsureWithinRoot – short (8.3) Windows paths
    // ──────────────────────────────────────────────
 
    [Fact]
    public void EnsureWithinRoot_ShortPathRoot_LongPathChild_DocumentedBehavior()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        // Short (8.3) paths are NOT consistently normalized by Path.GetFullPath
        // across .NET versions. This test documents that if the root uses a short
        // path form and the child uses the long form, the behavior depends on
        // whether GetFullPath expands 8.3 names on the current runtime.
        // This is acceptable because callers always construct paths relative
        // to the same root string via Path.Combine.
        string longRoot = Path.Combine(Path.GetTempPath(), "LongDirectoryName_ForTesting");
        Directory.CreateDirectory(longRoot);
        try
        {
            string shortRoot = GetShortPath(longRoot);
            if (string.Equals(shortRoot, longRoot, StringComparison.OrdinalIgnoreCase))
            {
                // 8.3 names not enabled on this volume — skip silently.
                return;
            }
 
            string longChild = Path.Combine(longRoot, "file.txt");
 
            // Check whether this runtime expands 8.3 names in GetFullPath.
            bool runtimeExpands8Dot3 = string.Equals(
                Path.GetFullPath(shortRoot),
                Path.GetFullPath(longRoot),
                StringComparison.OrdinalIgnoreCase);
 
            if (runtimeExpands8Dot3)
            {
                // GetFullPath normalizes both to long form — mixed usage works.
                string result = PathValidation.EnsureWithinRoot(shortRoot, longChild);
                Assert.Equal(Path.GetFullPath(longChild), result);
            }
            else
            {
                // GetFullPath preserves 8.3 — mixed representations don't match.
                Assert.Throws<InvalidOperationException>(() =>
                    PathValidation.EnsureWithinRoot(shortRoot, longChild));
            }
        }
        finally
        {
            Directory.Delete(longRoot, true);
        }
    }
 
    [Fact]
    public void EnsureWithinRoot_ConsistentShortPaths_Works()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        // When both root and child are constructed from the same short-path
        // string via Path.Combine, EnsureWithinRoot succeeds because
        // Path.GetFullPath treats both consistently.
        // Note: the child file must exist on disk because some runtimes
        // (e.g. .NET Framework) expand 8.3 names only for existing paths.
        string longRoot = Path.Combine(Path.GetTempPath(), "LongDirectoryName_ForTesting");
        Directory.CreateDirectory(longRoot);
        try
        {
            string shortRoot = GetShortPath(longRoot);
            if (string.Equals(shortRoot, longRoot, StringComparison.OrdinalIgnoreCase))
            {
                // 8.3 names not enabled on this volume — skip silently.
                return;
            }
 
            // Create the child file so GetFullPath expands consistently.
            string child = Path.Combine(shortRoot, "file.txt");
            File.WriteAllText(Path.Combine(longRoot, "file.txt"), string.Empty);
 
            string result = PathValidation.EnsureWithinRoot(shortRoot, child);
 
            Assert.Equal(Path.GetFullPath(child), result);
        }
        finally
        {
            Directory.Delete(longRoot, true);
        }
    }
 
    // ──────────────────────────────────────────────
    //  EnsureWithinRoot – additional edge cases
    // ──────────────────────────────────────────────
 
    [Fact]
    public void EnsureWithinRoot_AltSeparatorInRoot_Works()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        // Forward slash is an alternate directory separator on Windows.
        string root = Path.GetTempPath().Replace('\\', '/') + "testroot";
        string child = Path.Combine(root, "sub", "file.txt");
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    [Fact]
    public void EnsureWithinRoot_CaseMismatch_BehavesPerPlatform()
    {
        string root = Path.Combine(Path.GetTempPath(), "TestRoot");
        string child = Path.Combine(Path.GetTempPath(), "testroot", "file.txt");
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // Windows is case-insensitive: should succeed.
            string result = PathValidation.EnsureWithinRoot(root, child);
            Assert.Equal(Path.GetFullPath(child), result);
        }
        else
        {
            // Linux/macOS is case-sensitive: should throw.
            Assert.Throws<InvalidOperationException>(() =>
                PathValidation.EnsureWithinRoot(root, child));
        }
    }
 
    [Fact]
    public void EnsureWithinRoot_DriveRoot_ChildPath_Works()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"C:\";
        string child = @"C:\some\nested\file.txt";
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    [Fact]
    public void EnsureWithinRoot_DriveRoot_DifferentDrive_Throws()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Linux/macOS");
 
        string root = @"C:\data";
        string other = @"D:\data\file.txt";
 
        Assert.Throws<InvalidOperationException>(() =>
            PathValidation.EnsureWithinRoot(root, other));
    }
 
    [Fact]
    public void EnsureWithinRoot_UnixAbsoluteRoot_ChildPath_Works()
    {
        Assert.SkipUnless(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Skipped on Windows");
 
        string root = "/tmp/testroot";
        string child = "/tmp/testroot/sub/file.txt";
 
        string result = PathValidation.EnsureWithinRoot(root, child);
 
        Assert.Equal(Path.GetFullPath(child), result);
    }
 
    private static class NativeMethods
    {
        [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
        internal static extern uint GetShortPathNameW(string lpszLongPath, char[] lpszShortPath, uint cchBuffer);
    }
 
    private static string GetShortPath(string longPath)
    {
        var buffer = new char[260];
        uint len = NativeMethods.GetShortPathNameW(longPath, buffer, (uint)buffer.Length);
        return len > 0 ? new string(buffer, 0, (int)len) : longPath;
    }
}