File: Terminals\Hex1bPtySocketTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Net.Sockets;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.DotNet.RemoteExecutor;
 
#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.Tests.Terminals;
 
[Trait("Partition", "2")]
public class Hex1bPtySocketTests
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void Configure_OnUnix_LeavesEnvironmentAndFilesystemUnchanged(bool hasOverride)
    {
        Assert.SkipUnless(!OperatingSystem.IsWindows(), "Unix PTYs do not use the Windows proxy socket.");
 
        RemoteExecutor.Invoke(static hasOverrideValue =>
        {
            var root = Directory.CreateTempSubdirectory();
            try
            {
                var directory = Path.Combine(root.FullName, "custom");
                var value = bool.Parse(hasOverrideValue) ? directory : null;
                Environment.SetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable, value);
 
                Hex1bPtySocketHelper.Configure();
 
                Assert.Equal(value, Environment.GetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable));
                Assert.False(Directory.Exists(directory));
            }
            finally
            {
                root.Delete(recursive: true);
            }
        }, hasOverride.ToString()).Dispose();
    }
 
    [Theory]
    [InlineData(TerminalPlacement.Dock, false)]
    [InlineData(TerminalPlacement.Dock, true)]
    [InlineData(TerminalPlacement.Dialog, false)]
    [InlineData(TerminalPlacement.Dialog, true)]
    [SupportedOSPlatform("windows")]
    public void CreateTerminal_SecuresDirectoryBeforeDeferredStartup(TerminalPlacement placement, bool existingDirectory)
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows PTY socket permissions.");
 
        RemoteExecutor.Invoke(static async (placementValue, existingDirectoryValue) =>
        {
            var root = Directory.CreateTempSubdirectory();
            try
            {
                var directory = Path.Combine(root.FullName, "custom");
                if (bool.Parse(existingDirectoryValue))
                {
                    SocketPermissionHelper.CreateDirectory(directory, repairExisting: false);
                }
 
                // Normalize an existing override without confusing it with the terminal child's environment.
                Environment.SetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable,
                    Path.Combine(directory, "..", "custom"));
                await using (var service = TestTerminalService.Create())
                {
                    await using var terminal = service.CreateTerminal(new TerminalLaunchOptions
                    {
                        Title = "Deferred terminal",
                        Executable = "not-started",
                        Placement = Enum.Parse<TerminalPlacement>(placementValue),
                        EnvironmentVariables = { [Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable] = Path.Combine(root.FullName, "child") }
                    });
 
                    Assert.Equal(directory, Environment.GetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable));
                    var security = new DirectoryInfo(directory).GetAccessControl();
                    Assert.True(security.AreAccessRulesProtected);
                    using var identity = WindowsIdentity.GetCurrent();
                    Assert.Equal(identity.User, security.GetOwner(typeof(SecurityIdentifier)));
                    var rule = Assert.Single(security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>());
                    Assert.Equal(identity.User, rule.IdentityReference);
                    Assert.Equal(AccessControlType.Allow, rule.AccessControlType);
                    Assert.Equal(FileSystemRights.FullControl, rule.FileSystemRights);
                    Assert.Equal(InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, rule.InheritanceFlags);
                    Assert.Empty(Directory.EnumerateFileSystemEntries(directory));
                }
 
                Assert.Equal(directory, Environment.GetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable));
                Assert.True(Directory.Exists(directory));
            }
            finally
            {
                root.Delete(recursive: true);
            }
        }, placement.ToString(), existingDirectory.ToString()).Dispose();
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void CreateTerminal_InvalidDirectory_FailsBeforeRegistration(bool sharedRoot)
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows PTY socket permissions.");
 
        RemoteExecutor.Invoke(static async sharedRootValue =>
        {
            var root = Directory.CreateTempSubdirectory();
            try
            {
                Directory.CreateDirectory(Path.Combine(root.FullName, ".aspire"));
                var directory = Path.Combine(root.FullName, ".aspire", "pty");
                File.WriteAllText(directory, "not a directory");
                var value = bool.Parse(sharedRootValue) ? Path.GetTempPath() : directory;
                Environment.SetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable, value);
                await using var service = TestTerminalService.Create();
 
                Assert.ThrowsAny<IOException>(() => service.CreateTerminal(new TerminalLaunchOptions
                {
                    Title = "Rejected terminal",
                    Executable = "not-started"
                }));
 
                Assert.Empty(service.ListAll());
                Assert.Equal(value, Environment.GetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable));
                Assert.Equal("not a directory", File.ReadAllText(directory));
            }
            finally
            {
                root.Delete(recursive: true);
            }
        }, sharedRoot.ToString()).Dispose();
    }
 
    [Fact]
    [SupportedOSPlatform("windows")]
    public void CreateTerminal_PermissiveOverride_FailsWithoutChangingPermissions()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows PTY socket permissions.");
 
        RemoteExecutor.Invoke(static async () =>
        {
            var root = Directory.CreateTempSubdirectory();
            try
            {
                var directory = Directory.CreateDirectory(Path.Combine(root.FullName, "custom"));
                var security = directory.GetAccessControl();
                security.AddAccessRule(new FileSystemAccessRule(
                    new SecurityIdentifier(WellKnownSidType.WorldSid, null),
                    FileSystemRights.FullControl,
                    InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
                    PropagationFlags.None,
                    AccessControlType.Allow));
                directory.SetAccessControl(security);
                var originalPermissions = directory.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.All);
                Environment.SetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable, directory.FullName);
                await using var service = TestTerminalService.Create();
 
                Assert.Throws<IOException>(() => service.CreateTerminal(new TerminalLaunchOptions
                {
                    Title = "Rejected terminal",
                    Executable = "not-started"
                }));
 
                Assert.Empty(service.ListAll());
                Assert.Equal(originalPermissions, directory.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.All));
                Assert.Empty(directory.EnumerateFileSystemInfos());
            }
            finally
            {
                root.Delete(recursive: true);
            }
        }).Dispose();
    }
 
    [Fact]
    [SupportedOSPlatform("windows")]
    public void Start_ConcurrentDeferredTerminals_UsePrivateSockets()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Requires the Windows PTY proxy.");
 
        RemoteExecutor.Invoke(static async () =>
        {
            var root = Directory.CreateTempSubdirectory();
            try
            {
                // Windows adds a 12-character temporary directory name. Under the runner's
                // AppData\Local\Temp this leaves too little room for Hex1b's 47-character
                // "hex1bpty-{32 hex digits}.socket" name. Move the allocated directory to the
                // shorter profile path; MoveTo fails rather than reusing an existing directory.
                root.MoveTo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), root.Name));
                var directory = Path.Combine(root.FullName, ".aspire", "pty");
                _ = new UnixDomainSocketEndPoint(Path.Combine(directory, $"hex1bpty-{new string('0', 32)}.socket"));
                Environment.SetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable, directory);
                await using (var service = TestTerminalService.Create())
                {
                    await using var dock = service.CreateTerminal(new TerminalLaunchOptions
                    {
                        Title = "Dock",
                        Executable = "cmd.exe",
                        Arguments = ["/d", "/q", "/k", "echo dock-ready"],
                        Placement = TerminalPlacement.Dock
                    });
                    await using var prompt = service.CreateTerminal(new TerminalLaunchOptions
                    {
                        Title = "Prompt",
                        Executable = "cmd.exe",
                        Arguments = ["/d", "/q", "/k", "echo prompt-ready"],
                        Placement = TerminalPlacement.Dialog
                    });
 
                    Assert.Empty(Directory.EnumerateFileSystemEntries(directory));
                    await Task.WhenAll(
                        dock.WaitForTextAsync("dock-ready"),
                        prompt.WaitForTextAsync("prompt-ready")).DefaultTimeout();
 
                    var sockets = Directory.GetFiles(directory, "*.socket");
                    Assert.Equal(2, sockets.Length);
                    using var identity = WindowsIdentity.GetCurrent();
                    var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
                    foreach (var socket in sockets)
                    {
                        // The pinned Hex1b helper adds SYSTEM but must never grant other ordinary users access.
                        var rules = new FileInfo(socket).GetAccessControl()
                            .GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>().ToArray();
                        Assert.Contains(rules, rule => rule.IdentityReference.Equals(identity.User));
                        Assert.All(rules, rule =>
                        {
                            Assert.True(rule.IdentityReference.Equals(identity.User) || rule.IdentityReference.Equals(system));
                            Assert.Equal(AccessControlType.Allow, rule.AccessControlType);
                            Assert.Equal(FileSystemRights.FullControl, rule.FileSystemRights);
                        });
                    }
                }
 
                Assert.Empty(Directory.EnumerateFileSystemEntries(directory));
                Assert.Equal(directory, Environment.GetEnvironmentVariable(Hex1bPtySocketHelper.SocketDirectoryEnvironmentVariable));
            }
            finally
            {
                root.Delete(recursive: true);
            }
        }).Dispose();
    }
}