diff --git a/Assets/Tests/Editor/BridgeTransportEndpointTests.cs b/Assets/Tests/Editor/BridgeTransportEndpointTests.cs index bb81625bcf..6d457e9089 100644 --- a/Assets/Tests/Editor/BridgeTransportEndpointTests.cs +++ b/Assets/Tests/Editor/BridgeTransportEndpointTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Runtime.InteropServices; using Newtonsoft.Json.Linq; @@ -59,11 +60,31 @@ public void CreateProjectIpc_WhenCanonicalRootsFromSharedContract_MatchExpectedE } } + /// + /// Verifies the contract permits a symlinked parent but never a symlinked endpoint directory. + /// + [Test] + public void UnixSecurityPolicy_WhenRead_DefinesParentAndEndpointSymlinkBoundary() + { + JObject contract = ReadEndpointContract(); + JObject policy = contract.Value("unixSecurityPolicy"); + + Assert.That(policy, Is.Not.Null); + Assert.That(policy.Value("parentPath"), Is.EqualTo("/tmp")); + Assert.That(policy.Value("parentMayBeSymbolicLink"), Is.True); + Assert.That(policy.Value("resolvedParentOwnerUid"), Is.EqualTo(0)); + Assert.That(policy.Value("resolvedParentRequiresStickyBit"), Is.True); + Assert.That(policy.Value("endpointDirectoryMayBeSymbolicLink"), Is.False); + Assert.That(policy.Value("endpointDirectoryMode"), Is.EqualTo("0700")); + Assert.That( + policy.Value("endpointDirectoryRejectedSpecialModes")?.Values(), + Is.EqualTo(new[] { "04700", "02700" })); + Assert.That(policy.Value("socketMode"), Is.EqualTo("0600")); + } + private static IEnumerable ReadEndpointContractCases() { - string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); - string json = File.ReadAllText(Path.Combine(projectRoot, SharedEndpointContractPath)); - JObject contract = JObject.Parse(json); + JObject contract = ReadEndpointContract(); JArray cases = contract.Value("cases"); Assert.That(cases, Is.Not.Null.And.Not.Empty); foreach (JToken caseToken in cases) @@ -72,6 +93,13 @@ private static IEnumerable ReadEndpointContractCases() } } + private static JObject ReadEndpointContract() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string json = File.ReadAllText(Path.Combine(projectRoot, SharedEndpointContractPath)); + return JObject.Parse(json); + } + private static bool CanExerciseProjectRootOnCurrentPlatform(string projectRoot) { bool looksLikeWindowsPath = projectRoot.Length >= 2 && projectRoot[1] == ':'; @@ -90,7 +118,9 @@ private static string ExpectedEndpointPathForCurrentPlatform(JObject contractCas return contractCase.Value("windowsPipePath"); } - return contractCase.Value("unixSocketPath"); + string template = contractCase.Value("unixSocketPathTemplate"); + uint effectiveUserId = new UnixNativeFileSystem().GetEffectiveUserId(); + return template.Replace("", effectiveUserId.ToString(CultureInfo.InvariantCulture)); } } } diff --git a/Assets/Tests/Editor/BridgeTransportListenerTests.cs b/Assets/Tests/Editor/BridgeTransportListenerTests.cs index 426dcfe26e..ac92ace36b 100644 --- a/Assets/Tests/Editor/BridgeTransportListenerTests.cs +++ b/Assets/Tests/Editor/BridgeTransportListenerTests.cs @@ -2,6 +2,8 @@ using System.IO; using System.Threading; using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.Infrastructure; @@ -68,6 +70,65 @@ public void AcceptClient_AfterStop_ThrowsObjectDisposedException() Assert.Throws( () => listener.AcceptClient(CancellationToken.None)); } + + /// + /// Verifies listener startup leaves an untrusted regular file untouched instead of deleting it. + /// + [Test] + public void Start_WhenEndpointPathIsRegularFile_FailsWithoutDeletingFile() + { + string endpointDirectory = Path.GetDirectoryName(_endpoint.Path); + UnixEndpointSecurityResult directoryResult = new UnixEndpointSecurityPolicy(new UnixNativeFileSystem()) + .EnsureEndpointDirectory(endpointDirectory); + Assert.That(directoryResult.Success, Is.True, directoryResult.ErrorMessage); + File.WriteAllText(_endpoint.Path, "untrusted"); + UnixDomainSocketBridgeTransportListener listener = new(_endpoint); + + Assert.Throws(() => listener.Start()); + Assert.That(File.Exists(_endpoint.Path), Is.True); + } + + /// + /// Verifies listener startup uses a 0700 directory and creates a 0600 Unix socket. + /// + [Test] + public void Start_WhenSuccessful_UsesOwnerOnlyDirectoryAndSocket() + { + UnixDomainSocketBridgeTransportListener listener = new(_endpoint); + listener.Start(); + UnixNativeFileSystem fileSystem = new(); + + UnixFileMetadata directoryMetadata = fileSystem.ReadMetadata( + Path.GetDirectoryName(_endpoint.Path), + followSymbolicLinks: false); + UnixFileMetadata socketMetadata = fileSystem.ReadMetadata( + _endpoint.Path, + followSymbolicLinks: false); + + Assert.That(directoryMetadata.Kind, Is.EqualTo(UnixFileKind.Directory)); + Assert.That(directoryMetadata.Mode, Is.EqualTo(0x1C0)); + Assert.That(socketMetadata.Kind, Is.EqualTo(UnixFileKind.Socket)); + Assert.That(socketMetadata.Mode, Is.EqualTo(0x180)); + listener.Stop(); + } + + /// + /// Verifies teardown warns and preserves an untrusted replacement instead of throwing or deleting it. + /// + [Test] + public void Stop_WhenSocketWasReplacedWithRegularFile_WarnsAndPreservesFile() + { + UnixDomainSocketBridgeTransportListener listener = new(_endpoint); + listener.Start(); + File.Delete(_endpoint.Path); + File.WriteAllText(_endpoint.Path, "replacement"); + LogAssert.Expect( + LogType.Warning, + new System.Text.RegularExpressions.Regex("Refusing to remove untrusted existing Unix endpoint")); + + Assert.DoesNotThrow(() => listener.Stop()); + Assert.That(File.Exists(_endpoint.Path), Is.True); + } } /// diff --git a/Assets/Tests/Editor/UnixEndpointSecurityTests.cs b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs new file mode 100644 index 0000000000..b60d59be17 --- /dev/null +++ b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies Unix endpoint directory and socket policy without depending on host ownership. + /// + public class UnixEndpointSecurityTests + { + private const string ParentPath = "/tmp"; + private const string EndpointDirectoryPath = "/tmp/uloop-501"; + private const string SocketPath = EndpointDirectoryPath + "/UnityCliLoop-test.sock"; + private const uint EffectiveUserId = 501; + + /// + /// Verifies a symlinked /tmp spelling is accepted when its followed target is root-owned and sticky. + /// + [Test] + public void EnsureEndpointDirectory_WhenParentIsSymlinkToSecureDirectory_Succeeds() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(ParentPath, false, Metadata(UnixFileKind.SymbolicLink, 0, 0x1FF)); + + UnixEndpointSecurityPolicy policy = new(fileSystem); + UnixEndpointSecurityResult result = policy.EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + } + + /// + /// Verifies the followed /tmp target must remain a root-owned sticky directory. + /// + [Test] + public void EnsureEndpointDirectory_WhenResolvedParentIsNotSticky_FailsClosed() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(ParentPath, true, Metadata(UnixFileKind.Directory, 0, 0x1FF)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + } + + /// + /// Verifies an endpoint-directory symlink is rejected even when its target would be secure. + /// + [Test] + public void EnsureEndpointDirectory_WhenEndpointDirectoryIsSymlink_FailsClosed() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.SymbolicLink, EffectiveUserId, 0x1C0)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + } + + /// + /// Verifies an endpoint directory owned by another OS user is rejected without repair. + /// + [Test] + public void EnsureEndpointDirectory_WhenOwnerDoesNotMatchEffectiveUser_FailsWithoutChmod() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.Directory, EffectiveUserId + 1, 0x1C0)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + Assert.That(fileSystem.ChangedModes, Is.Empty); + } + + /// + /// Verifies non-0700 permissions and special mode bits are rejected instead of auto-repaired. + /// + [TestCase(0x1F8u)] + [TestCase(0x1C7u)] + [TestCase(0x9C0u)] + [TestCase(0x5C0u)] + public void EnsureEndpointDirectory_WhenExistingModeIsNot0700_FailsWithoutChmod(uint mode) + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.Directory, EffectiveUserId, mode)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + Assert.That(fileSystem.ChangedModes, Is.Empty); + } + + /// + /// Verifies a missing endpoint directory is created atomically with a non-widening 0700 mode. + /// + [Test] + public void EnsureEndpointDirectory_WhenMissing_CreatesWith0700() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(EndpointDirectoryPath, false, UnixFileMetadata.Missing()); + fileSystem.MetadataAfterCreate = Metadata(UnixFileKind.Directory, EffectiveUserId, 0x1C0); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(fileSystem.CreatedModes, Is.EqualTo(new[] { 0x1C0u })); + } + + /// + /// Verifies mkdir EEXIST joins the existing-directory validation path without changing permissions. + /// + [Test] + public void EnsureEndpointDirectory_WhenCreateReportsAlreadyExists_RevalidatesWithoutRepair() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(EndpointDirectoryPath, false, UnixFileMetadata.Missing()); + fileSystem.CreateResult = UnixNativeOperationResult.Failure(UnixNativeError.AlreadyExists); + fileSystem.MetadataAfterCreate = Metadata(UnixFileKind.Directory, EffectiveUserId, 0x1F8); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + Assert.That(fileSystem.ChangedModes, Is.Empty); + } + + /// + /// Verifies only an owner-only socket may be removed as a stale endpoint. + /// + [Test] + public void ValidateStaleSocket_WhenPathIsRegularFile_FailsClosed() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(SocketPath, false, Metadata(UnixFileKind.RegularFile, EffectiveUserId, 0x180)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .ValidateStaleSocket(SocketPath); + + Assert.That(result.Success, Is.False); + } + + /// + /// Verifies an owner-owned socket left before chmod may be removed after a crash. + /// + [Test] + public void ValidateStaleSocket_WhenOwnerSocketHasPermissiveMode_Succeeds() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(SocketPath, false, Metadata(UnixFileKind.Socket, EffectiveUserId, 0x1ED)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .ValidateStaleSocket(SocketPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + } + + /// + /// Verifies a freshly bound socket is restricted to 0600 before listener startup succeeds. + /// + [Test] + public void RestrictSocket_WhenCalled_ChangesModeTo0600() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(SocketPath, false, Metadata(UnixFileKind.Socket, EffectiveUserId, 0x1C0)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .RestrictSocket(SocketPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(fileSystem.ChangedModes, Is.EqualTo(new[] { 0x180u })); + } + + /// + /// Verifies the native stat ABI reports real uid and mode values from the host filesystem. + /// + [Test] + public void UnixNativeFileSystem_WhenReadingRealDirectory_ReportsCurrentOwnerAnd0700() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("POSIX metadata is unavailable on Windows."); + } + + UnixNativeFileSystem fileSystem = new(); + string directoryPath = Path.Combine( + Path.GetTempPath(), + "uloop-native-stat-test-" + Guid.NewGuid().ToString("N")); + UnixNativeOperationResult createResult = fileSystem.CreateDirectory(directoryPath, 0x1C0); + Assert.That(createResult.Success, Is.True, $"mkdir errno {createResult.ErrorCode}"); + + try + { + UnixFileMetadata metadata = fileSystem.ReadMetadata( + directoryPath, + followSymbolicLinks: false); + Assert.That(metadata.ReadSuccess, Is.True, $"lstat errno {metadata.ErrorCode}"); + Assert.That(metadata.Kind, Is.EqualTo(UnixFileKind.Directory)); + Assert.That(metadata.OwnerUserId, Is.EqualTo(fileSystem.GetEffectiveUserId())); + Assert.That(metadata.Mode, Is.EqualTo(0x1C0)); + } + finally + { + Directory.Delete(directoryPath); + } + } + + /// + /// Verifies lstat may observe /tmp as a symlink while stat follows it to a root-owned sticky directory. + /// + [Test] + public void UnixNativeFileSystem_WhenReadingTmp_FollowsOnlyTheParentStatPath() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("POSIX metadata is unavailable on Windows."); + } + + UnixNativeFileSystem fileSystem = new(); + UnixFileMetadata noFollow = fileSystem.ReadMetadata(ParentPath, followSymbolicLinks: false); + UnixFileMetadata followed = fileSystem.ReadMetadata(ParentPath, followSymbolicLinks: true); + + Assert.That(noFollow.ReadSuccess, Is.True, $"lstat errno {noFollow.ErrorCode}"); + Assert.That( + noFollow.Kind, + Is.EqualTo(UnixFileKind.Directory).Or.EqualTo(UnixFileKind.SymbolicLink)); + Assert.That(followed.ReadSuccess, Is.True, $"stat errno {followed.ErrorCode}"); + Assert.That(followed.Kind, Is.EqualTo(UnixFileKind.Directory)); + Assert.That(followed.OwnerUserId, Is.EqualTo(0)); + Assert.That(followed.Mode & 0x200, Is.Not.EqualTo(0)); + } + + /// + /// Verifies each supported Editor OS and architecture selects the reviewed stat ABI offsets. + /// + [TestCase(true, false, Architecture.X64, 4, 2, 16, true)] + [TestCase(true, false, Architecture.Arm64, 4, 2, 16, false)] + [TestCase(false, true, Architecture.X64, 24, 4, 28, false)] + [TestCase(false, true, Architecture.Arm64, 16, 4, 24, false)] + public void UnixStatLayoutResolver_WhenPlatformIsSupported_ReturnsReviewedOffsets( + bool isMacOS, + bool isLinux, + Architecture architecture, + int expectedModeOffset, + int expectedModeSize, + int expectedOwnerUserIdOffset, + bool expectedDarwinInode64Symbols) + { + UnixStatLayoutResult result = UnixStatLayoutResolver.Resolve( + isMacOS, + isLinux, + architecture); + + Assert.That(result.Success, Is.True); + Assert.That(result.ModeOffset, Is.EqualTo(expectedModeOffset)); + Assert.That(result.ModeSize, Is.EqualTo(expectedModeSize)); + Assert.That(result.OwnerUserIdOffset, Is.EqualTo(expectedOwnerUserIdOffset)); + Assert.That(result.UseDarwinInode64Symbols, Is.EqualTo(expectedDarwinInode64Symbols)); + } + + /// + /// Verifies an unreviewed OS or architecture cannot fall through to a nearby stat ABI layout. + /// + [TestCase(false, true, Architecture.X86)] + [TestCase(false, false, Architecture.X64)] + public void UnixStatLayoutResolver_WhenPlatformIsUnsupported_FailsClosed( + bool isMacOS, + bool isLinux, + Architecture architecture) + { + UnixStatLayoutResult result = UnixStatLayoutResolver.Resolve( + isMacOS, + isLinux, + architecture); + + Assert.That(result.Success, Is.False); + } + + private static FakeUnixNativeFileSystem CreateSecureFileSystem() + { + FakeUnixNativeFileSystem fileSystem = new(EffectiveUserId); + fileSystem.SetMetadata(ParentPath, false, Metadata(UnixFileKind.Directory, 0, 0x3FF)); + fileSystem.SetMetadata(ParentPath, true, Metadata(UnixFileKind.Directory, 0, 0x3FF)); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.Directory, EffectiveUserId, 0x1C0)); + return fileSystem; + } + + private static UnixFileMetadata Metadata(UnixFileKind kind, uint ownerUserId, uint mode) + { + return UnixFileMetadata.Existing(kind, ownerUserId, mode); + } + + private sealed class FakeUnixNativeFileSystem : IUnixNativeFileSystem + { + private readonly Dictionary _noFollowMetadata = new(); + private readonly Dictionary _followMetadata = new(); + + public uint EffectiveUserId { get; } + public List CreatedModes { get; } = new(); + public List ChangedModes { get; } = new(); + public UnixFileMetadata MetadataAfterCreate { get; set; } + public UnixNativeOperationResult CreateResult { get; set; } = UnixNativeOperationResult.Successful(); + + public FakeUnixNativeFileSystem(uint effectiveUserId) + { + EffectiveUserId = effectiveUserId; + MetadataAfterCreate = UnixFileMetadata.Missing(); + } + + public uint GetEffectiveUserId() + { + return EffectiveUserId; + } + + public UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks) + { + Dictionary metadata = + followSymbolicLinks ? _followMetadata : _noFollowMetadata; + if (metadata.ContainsKey(path)) + { + return metadata[path]; + } + + return UnixFileMetadata.Missing(); + } + + public UnixNativeOperationResult CreateDirectory(string path, uint mode) + { + CreatedModes.Add(mode); + SetMetadata(path, false, MetadataAfterCreate); + return CreateResult; + } + + public UnixNativeOperationResult ChangeMode(string path, uint mode) + { + ChangedModes.Add(mode); + if (_noFollowMetadata.ContainsKey(path)) + { + UnixFileMetadata current = _noFollowMetadata[path]; + _noFollowMetadata[path] = UnixFileMetadata.Existing( + current.Kind, + current.OwnerUserId, + mode); + } + return UnixNativeOperationResult.Successful(); + } + + public void SetMetadata( + string path, + bool followSymbolicLinks, + UnixFileMetadata metadata) + { + Dictionary target = + followSymbolicLinks ? _followMetadata : _noFollowMetadata; + target[path] = metadata; + } + } + } +} diff --git a/Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta new file mode 100644 index 0000000000..ce6781de91 --- /dev/null +++ b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e08821a99a1b4605ae82b5988c761e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 786fb76377..90289497ba 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -7,9 +7,9 @@ public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; // Why: the runtime IPC gate compares this contract generation, not release numbers. - // Bump it together with cli/contract.json protocolVersion only when this package can + // Bump it together with cli/common/clicontract/contract.json protocolVersion only when this package can // no longer interoperate with a different CLI protocol generation. - public const int REQUIRED_CLI_PROTOCOL_VERSION = 3; + public const int REQUIRED_CLI_PROTOCOL_VERSION = 4; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; public const string JSON_FLAG = "--json"; diff --git a/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs b/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs index bff5fddedf..9ed053324c 100644 --- a/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs +++ b/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text; using UnityEngine; @@ -41,9 +42,14 @@ public static BridgeTransportEndpoint CreateProjectIpc(string projectRoot) pipeName); } + uint effectiveUserId = new UnixNativeFileSystem().GetEffectiveUserId(); + string endpointDirectory = System.IO.Path.Combine( + UnixEndpointSecurityConstants.ParentPath, + UnixEndpointSecurityConstants.EndpointDirectoryPrefix + + effectiveUserId.ToString(CultureInfo.InvariantCulture)); return new BridgeTransportEndpoint( BridgeTransportKind.UnixDomainSocket, - System.IO.Path.Combine("/tmp/uloop", endpointName + ".sock"), + System.IO.Path.Combine(endpointDirectory, endpointName + ".sock"), string.Empty); } diff --git a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs index 43896488c9..6f4b78a45d 100644 --- a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs +++ b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs @@ -85,6 +85,7 @@ public static IBridgeTransportListener Create(BridgeTransportEndpoint endpoint) /// internal sealed class UnixDomainSocketBridgeTransportListener : IBridgeTransportListener { + private readonly UnixEndpointSecurityPolicy _securityPolicy = new(new UnixNativeFileSystem()); private Socket _listener; private long _nextClientId; @@ -98,11 +99,17 @@ public UnixDomainSocketBridgeTransportListener(BridgeTransportEndpoint endpoint) public void Start() { string directory = Path.GetDirectoryName(Endpoint.Path); - if (!Directory.Exists(directory)) + UnixEndpointSecurityResult directoryResult = _securityPolicy.EnsureEndpointDirectory(directory); + if (!directoryResult.Success) { - Directory.CreateDirectory(directory); + throw new IOException(directoryResult.ErrorMessage); } + UnixEndpointSecurityResult staleSocketResult = _securityPolicy.ValidateStaleSocket(Endpoint.Path); + if (!staleSocketResult.Success) + { + throw new IOException(staleSocketResult.ErrorMessage); + } if (File.Exists(Endpoint.Path)) { File.Delete(Endpoint.Path); @@ -110,6 +117,13 @@ public void Start() _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); _listener.Bind(new UnixDomainSocketEndPoint(Endpoint.Path)); + UnixEndpointSecurityResult socketModeResult = _securityPolicy.RestrictSocket(Endpoint.Path); + if (!socketModeResult.Success) + { + _listener.Close(); + _listener = null; + throw new IOException(socketModeResult.ErrorMessage); + } _listener.Listen(100); } @@ -160,6 +174,14 @@ public void Stop() listener.Close(); if (File.Exists(Endpoint.Path)) { + UnixEndpointSecurityResult staleSocketResult = _securityPolicy.ValidateStaleSocket(Endpoint.Path); + if (!staleSocketResult.Success) + { + // Why cleanup does not throw: refusing to delete an untrusted replacement is + // already the safe outcome, while domain-reload teardown must remain reliable. + UnityEngine.Debug.LogWarning(staleSocketResult.ErrorMessage); + return; + } File.Delete(Endpoint.Path); } } diff --git a/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs new file mode 100644 index 0000000000..84ef5fcf35 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs @@ -0,0 +1,284 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + internal static class UnixEndpointSecurityConstants + { + public const string ParentPath = "/tmp"; + public const string EndpointDirectoryPrefix = "uloop-"; + public const uint RootUserId = 0; + public const uint StickyBit = 0x200; + public const uint EndpointDirectoryMode = 0x1C0; + public const uint SocketMode = 0x180; + } + + internal enum UnixFileKind + { + Missing, + Directory, + SymbolicLink, + Socket, + RegularFile, + Other + } + + internal readonly struct UnixFileMetadata + { + public bool ReadSuccess { get; } + public int ErrorCode { get; } + public UnixFileKind Kind { get; } + public uint OwnerUserId { get; } + public uint Mode { get; } + + private UnixFileMetadata( + bool readSuccess, + int errorCode, + UnixFileKind kind, + uint ownerUserId, + uint mode) + { + ReadSuccess = readSuccess; + ErrorCode = errorCode; + Kind = kind; + OwnerUserId = ownerUserId; + Mode = mode; + } + + public static UnixFileMetadata Existing(UnixFileKind kind, uint ownerUserId, uint mode) + { + return new UnixFileMetadata(true, 0, kind, ownerUserId, mode); + } + + public static UnixFileMetadata Missing() + { + return new UnixFileMetadata(true, 0, UnixFileKind.Missing, 0, 0); + } + + public static UnixFileMetadata Failure(int errorCode) + { + return new UnixFileMetadata(false, errorCode, UnixFileKind.Other, 0, 0); + } + } + + internal readonly struct UnixNativeOperationResult + { + public bool Success { get; } + public int ErrorCode { get; } + + private UnixNativeOperationResult(bool success, int errorCode) + { + Success = success; + ErrorCode = errorCode; + } + + public static UnixNativeOperationResult Successful() + { + return new UnixNativeOperationResult(true, 0); + } + + public static UnixNativeOperationResult Failure(int errorCode) + { + return new UnixNativeOperationResult(false, errorCode); + } + } + + internal static class UnixNativeError + { + public const int NoEntry = 2; + public const int AlreadyExists = 17; + } + + internal interface IUnixNativeFileSystem + { + uint GetEffectiveUserId(); + UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks); + UnixNativeOperationResult CreateDirectory(string path, uint mode); + UnixNativeOperationResult ChangeMode(string path, uint mode); + } + + internal readonly struct UnixEndpointSecurityResult + { + public bool Success { get; } + public string ErrorMessage { get; } + + private UnixEndpointSecurityResult(bool success, string errorMessage) + { + Success = success; + ErrorMessage = errorMessage; + } + + public static UnixEndpointSecurityResult Successful() + { + return new UnixEndpointSecurityResult(true, string.Empty); + } + + public static UnixEndpointSecurityResult Failure(string errorMessage) + { + return new UnixEndpointSecurityResult(false, errorMessage); + } + } + + /// + /// Validates the Unix parent, per-user endpoint directory, and socket before filesystem mutation. + /// + internal sealed class UnixEndpointSecurityPolicy + { + private readonly IUnixNativeFileSystem _fileSystem; + + public UnixEndpointSecurityPolicy(IUnixNativeFileSystem fileSystem) + { + System.Diagnostics.Debug.Assert(fileSystem != null, "fileSystem must not be null"); + _fileSystem = fileSystem; + } + + public UnixEndpointSecurityResult EnsureEndpointDirectory(string endpointDirectoryPath) + { + System.Diagnostics.Debug.Assert( + !string.IsNullOrWhiteSpace(endpointDirectoryPath), + "endpointDirectoryPath must not be empty"); + + UnixEndpointSecurityResult parentResult = ValidateParent(); + if (!parentResult.Success) + { + return parentResult; + } + + UnixFileMetadata endpointMetadata = _fileSystem.ReadMetadata( + endpointDirectoryPath, + followSymbolicLinks: false); + if (!endpointMetadata.ReadSuccess) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to inspect Unix endpoint directory {endpointDirectoryPath}: errno {endpointMetadata.ErrorCode}"); + } + + if (endpointMetadata.Kind == UnixFileKind.Missing) + { + UnixNativeOperationResult createResult = _fileSystem.CreateDirectory( + endpointDirectoryPath, + UnixEndpointSecurityConstants.EndpointDirectoryMode); + if (!createResult.Success && createResult.ErrorCode != UnixNativeError.AlreadyExists) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to create Unix endpoint directory {endpointDirectoryPath}: errno {createResult.ErrorCode}"); + } + + // Why: EEXIST can mean another process won the creation race. Both successful + // creation and EEXIST must converge on the same no-follow owner/mode validation. + endpointMetadata = _fileSystem.ReadMetadata( + endpointDirectoryPath, + followSymbolicLinks: false); + if (!endpointMetadata.ReadSuccess) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to reinspect Unix endpoint directory {endpointDirectoryPath}: errno {endpointMetadata.ErrorCode}"); + } + } + + return ValidateEndpointDirectoryMetadata(endpointDirectoryPath, endpointMetadata); + } + + public UnixEndpointSecurityResult ValidateStaleSocket(string socketPath) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(socketPath), "socketPath must not be empty"); + + UnixFileMetadata metadata = _fileSystem.ReadMetadata(socketPath, followSymbolicLinks: false); + if (!metadata.ReadSuccess) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to inspect Unix socket {socketPath}: errno {metadata.ErrorCode}"); + } + if (metadata.Kind == UnixFileKind.Missing) + { + return UnixEndpointSecurityResult.Successful(); + } + if (metadata.Kind != UnixFileKind.Socket || + metadata.OwnerUserId != _fileSystem.GetEffectiveUserId()) + { + return UnixEndpointSecurityResult.Failure( + $"Refusing to remove untrusted existing Unix endpoint {socketPath}"); + } + + return UnixEndpointSecurityResult.Successful(); + } + + public UnixEndpointSecurityResult RestrictSocket(string socketPath) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(socketPath), "socketPath must not be empty"); + + // Why: bind may honor a permissive process umask, but the containing directory is + // already 0700, so other users cannot reach the socket before this chmod to 0600. + UnixNativeOperationResult result = _fileSystem.ChangeMode( + socketPath, + UnixEndpointSecurityConstants.SocketMode); + if (!result.Success) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to restrict Unix socket {socketPath} to mode 0600: errno {result.ErrorCode}"); + } + + UnixFileMetadata metadata = _fileSystem.ReadMetadata(socketPath, followSymbolicLinks: false); + if (!metadata.ReadSuccess || + metadata.Kind != UnixFileKind.Socket || + metadata.OwnerUserId != _fileSystem.GetEffectiveUserId() || + metadata.Mode != UnixEndpointSecurityConstants.SocketMode) + { + return UnixEndpointSecurityResult.Failure( + $"Unix socket {socketPath} did not retain owner-only mode 0600"); + } + + return UnixEndpointSecurityResult.Successful(); + } + + private UnixEndpointSecurityResult ValidateParent() + { + UnixFileMetadata parentNoFollow = _fileSystem.ReadMetadata( + UnixEndpointSecurityConstants.ParentPath, + followSymbolicLinks: false); + if (!parentNoFollow.ReadSuccess || + (parentNoFollow.Kind != UnixFileKind.Directory && + parentNoFollow.Kind != UnixFileKind.SymbolicLink)) + { + return UnixEndpointSecurityResult.Failure( + "Unix endpoint parent /tmp must be a directory or a symbolic link to one"); + } + + UnixFileMetadata parentFollowed = _fileSystem.ReadMetadata( + UnixEndpointSecurityConstants.ParentPath, + followSymbolicLinks: true); + if (!parentFollowed.ReadSuccess || + parentFollowed.Kind != UnixFileKind.Directory || + parentFollowed.OwnerUserId != UnixEndpointSecurityConstants.RootUserId || + (parentFollowed.Mode & UnixEndpointSecurityConstants.StickyBit) == 0) + { + return UnixEndpointSecurityResult.Failure( + "Resolved Unix endpoint parent /tmp must be a root-owned sticky directory"); + } + + return UnixEndpointSecurityResult.Successful(); + } + + private UnixEndpointSecurityResult ValidateEndpointDirectoryMetadata( + string endpointDirectoryPath, + UnixFileMetadata metadata) + { + if (metadata.Kind != UnixFileKind.Directory) + { + return UnixEndpointSecurityResult.Failure( + $"Unix endpoint directory {endpointDirectoryPath} must be a real directory"); + } + if (metadata.OwnerUserId != _fileSystem.GetEffectiveUserId()) + { + return UnixEndpointSecurityResult.Failure( + $"Unix endpoint directory {endpointDirectoryPath} is not owned by the current user"); + } + if (metadata.Mode != UnixEndpointSecurityConstants.EndpointDirectoryMode) + { + // Why not chmod an existing directory: an attacker-controlled or accidentally + // shared path must be rejected, not transformed into a path the process trusts. + return UnixEndpointSecurityResult.Failure( + $"Unix endpoint directory {endpointDirectoryPath} must already have mode 0700"); + } + + return UnixEndpointSecurityResult.Successful(); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta new file mode 100644 index 0000000000..48ed1d9243 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d171f320293b4624baea80f9714ad7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs new file mode 100644 index 0000000000..89c3d8f388 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs @@ -0,0 +1,271 @@ +using System.Runtime.InteropServices; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Provides the minimal POSIX metadata operations needed to secure the Unix IPC endpoint. + /// + internal sealed class UnixNativeFileSystem : IUnixNativeFileSystem + { + private const int UnsupportedPlatformError = 38; + private const int UnixNativeStatBufferSize = 144; + private const uint FileTypeMask = 0xF000; + private const uint DirectoryType = 0x4000; + private const uint RegularFileType = 0x8000; + private const uint SymbolicLinkType = 0xA000; + private const uint SocketType = 0xC000; + private const uint PermissionMask = 0x0FFF; + + public uint GetEffectiveUserId() + { +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return GetEffectiveUserIdNative(); +#else + return uint.MaxValue; +#endif + } + + public UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be empty"); + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + if (!System.BitConverter.IsLittleEndian) + { + return UnixFileMetadata.Failure(UnsupportedPlatformError); + } + + UnixStatLayoutResult layoutResult = UnixStatLayoutResolver.Resolve( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + RuntimeInformation.ProcessArchitecture); + if (!layoutResult.Success) + { + return UnixFileMetadata.Failure(UnsupportedPlatformError); + } + + // Why one shared buffer: libc stat and lstat write the same platform struct stat. + // A single maximum-sized buffer keeps their follow/no-follow ABI paths identical; + // Linux arm64 writes its 128-byte structure into this 144-byte allocation safely. + byte[] buffer = new byte[UnixNativeStatBufferSize]; + int result = ReadNativeStat( + path, + followSymbolicLinks, + layoutResult.UseDarwinInode64Symbols, + buffer); + if (result != 0) + { + int errorCode = Marshal.GetLastWin32Error(); + return errorCode == UnixNativeError.NoEntry + ? UnixFileMetadata.Missing() + : UnixFileMetadata.Failure(errorCode); + } + + uint mode = ReadUnsignedInteger(buffer, layoutResult.ModeOffset, layoutResult.ModeSize); + uint ownerUserId = ReadUnsignedInteger( + buffer, + layoutResult.OwnerUserIdOffset, + sizeof(uint)); + return UnixFileMetadata.Existing( + ToFileKind(mode), + ownerUserId, + mode & PermissionMask); +#else + return UnixFileMetadata.Failure(UnsupportedPlatformError); +#endif + } + + public UnixNativeOperationResult CreateDirectory(string path, uint mode) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be empty"); + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + // Why this is atomic: mkdir applies the requested 0700 before publishing the + // directory. A process umask can remove permission bits, but can never widen 0700. + int result = MakeDirectoryNative(path, mode); + return result == 0 + ? UnixNativeOperationResult.Successful() + : UnixNativeOperationResult.Failure(Marshal.GetLastWin32Error()); +#else + return UnixNativeOperationResult.Failure(UnsupportedPlatformError); +#endif + } + + public UnixNativeOperationResult ChangeMode(string path, uint mode) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be empty"); + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + int result = ChangeModeNative(path, mode); + return result == 0 + ? UnixNativeOperationResult.Successful() + : UnixNativeOperationResult.Failure(Marshal.GetLastWin32Error()); +#else + return UnixNativeOperationResult.Failure(UnsupportedPlatformError); +#endif + } + + private static UnixFileKind ToFileKind(uint mode) + { + uint fileType = mode & FileTypeMask; + switch (fileType) + { + case DirectoryType: + return UnixFileKind.Directory; + case RegularFileType: + return UnixFileKind.RegularFile; + case SymbolicLinkType: + return UnixFileKind.SymbolicLink; + case SocketType: + return UnixFileKind.Socket; + default: + return UnixFileKind.Other; + } + } + + private static uint ReadUnsignedInteger(byte[] buffer, int offset, int size) + { + System.Diagnostics.Debug.Assert(buffer != null, "buffer must not be null"); + System.Diagnostics.Debug.Assert(offset >= 0, "offset must not be negative"); + System.Diagnostics.Debug.Assert(size == sizeof(ushort) || size == sizeof(uint), "size must be 2 or 4"); + System.Diagnostics.Debug.Assert(offset + size <= buffer.Length, "field must fit in buffer"); + + return size == sizeof(ushort) + ? System.BitConverter.ToUInt16(buffer, offset) + : System.BitConverter.ToUInt32(buffer, offset); + } + + private static int ReadNativeStat( + string path, + bool followSymbolicLinks, + bool useDarwinInode64Symbols, + byte[] buffer) + { +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + if (useDarwinInode64Symbols) + { + // Why Intel macOS needs decorated symbols: Darwin's headers redirect the + // 64-bit-inode struct stat ABI to stat$INODE64/lstat$INODE64, while the plain + // legacy symbols use a different layout on x86_64. + // Source: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/cdefs.h + return followSymbolicLinks + ? StatInode64Native(path, buffer) + : LStatInode64Native(path, buffer); + } + + return followSymbolicLinks + ? StatNative(path, buffer) + : LStatNative(path, buffer); +#else + return -1; +#endif + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + [DllImport("libc", EntryPoint = "geteuid", SetLastError = true)] + private static extern uint GetEffectiveUserIdNative(); + + [DllImport("libc", EntryPoint = "lstat", SetLastError = true)] + private static extern int LStatNative(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "stat", SetLastError = true)] + private static extern int StatNative(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "lstat$INODE64", SetLastError = true)] + private static extern int LStatInode64Native(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "stat$INODE64", SetLastError = true)] + private static extern int StatInode64Native(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "chmod", SetLastError = true)] + private static extern int ChangeModeNative(string path, uint mode); + + [DllImport("libc", EntryPoint = "mkdir", SetLastError = true)] + private static extern int MakeDirectoryNative(string path, uint mode); + +#endif + } + + internal readonly struct UnixStatLayoutResult + { + public bool Success { get; } + public int ModeOffset { get; } + public int ModeSize { get; } + public int OwnerUserIdOffset { get; } + public bool UseDarwinInode64Symbols { get; } + + private UnixStatLayoutResult( + bool success, + int modeOffset, + int modeSize, + int ownerUserIdOffset, + bool useDarwinInode64Symbols) + { + Success = success; + ModeOffset = modeOffset; + ModeSize = modeSize; + OwnerUserIdOffset = ownerUserIdOffset; + UseDarwinInode64Symbols = useDarwinInode64Symbols; + } + + public static UnixStatLayoutResult Supported( + int modeOffset, + int modeSize, + int ownerUserIdOffset, + bool useDarwinInode64Symbols) + { + return new UnixStatLayoutResult( + true, + modeOffset, + modeSize, + ownerUserIdOffset, + useDarwinInode64Symbols); + } + + public static UnixStatLayoutResult Unsupported() + { + return new UnixStatLayoutResult(false, 0, 0, 0, false); + } + } + + internal static class UnixStatLayoutResolver + { + public static UnixStatLayoutResult Resolve( + bool isMacOS, + bool isLinux, + Architecture architecture) + { + if (isMacOS && (architecture == Architecture.X64 || architecture == Architecture.Arm64)) + { + // Why these offsets: Darwin stat64 defines st_mode after st_dev at byte 4 and + // st_uid at byte 16 for both supported 64-bit Editor architectures. + // Source: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/stat.h + return UnixStatLayoutResult.Supported( + 4, + sizeof(ushort), + 16, + useDarwinInode64Symbols: architecture == Architecture.X64); + } + + if (isLinux && architecture == Architecture.X64) + { + // Why these offsets: glibc x86_64 struct stat places st_mode at byte 24 and + // st_uid at byte 28 in its 144-byte layout. + // Source: https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/x86/bits/struct_stat.h + return UnixStatLayoutResult.Supported(24, sizeof(uint), 28, useDarwinInode64Symbols: false); + } + + if (isLinux && architecture == Architecture.Arm64) + { + // Why these offsets: glibc AArch64 struct stat places st_mode at byte 16 and + // st_uid at byte 24 in its 128-byte layout. + // Source: https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/bits/struct_stat.h + return UnixStatLayoutResult.Supported(16, sizeof(uint), 24, useDarwinInode64Symbols: false); + } + + // Why fail closed: selecting a nearby ABI layout can silently misread owner/mode + // metadata and turn a validation error into acceptance of an untrusted endpoint. + return UnixStatLayoutResult.Unsupported(); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta new file mode 100644 index 0000000000..a49ce08fdf --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc5a65fd6a8814b51a1578abb7d3ac51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/common/clicontract/contract.json b/cli/common/clicontract/contract.json index 1aba4491cb..71c8d499ee 100644 --- a/cli/common/clicontract/contract.json +++ b/cli/common/clicontract/contract.json @@ -1,4 +1,4 @@ { - "protocolVersion": 3, + "protocolVersion": 4, "projectRunnerVersion": "3.0.0-beta.47" } diff --git a/cli/common/errors/error_envelope_classification.go b/cli/common/errors/error_envelope_classification.go index de3156204e..9942c37841 100644 --- a/cli/common/errors/error_envelope_classification.go +++ b/cli/common/errors/error_envelope_classification.go @@ -44,6 +44,11 @@ func classifyTypedError(err error, context ErrorContext) (CLIError, bool) { } func classifyUnityConnectionError(err error, context ErrorContext) (CLIError, bool) { + var endpointNotCreatedErr UnityEndpointNotCreatedError + if errors.As(err, &endpointNotCreatedErr) { + return unityEndpointNotCreatedCLIError(endpointNotCreatedErr, context), true + } + var notRespondingErr UnityServerNotRespondingError if errors.As(err, ¬RespondingErr) { return unityServerNotRespondingCLIError(notRespondingErr, context), true diff --git a/cli/common/errors/unity_endpoint_not_created.go b/cli/common/errors/unity_endpoint_not_created.go new file mode 100644 index 0000000000..a1401cba8c --- /dev/null +++ b/cli/common/errors/unity_endpoint_not_created.go @@ -0,0 +1,25 @@ +package clierrors + +import "github.com/hatayama/unity-cli-loop/common/ipcendpoint" + +type UnityEndpointNotCreatedError = ipcendpoint.UnityEndpointNotCreatedError + +func unityEndpointNotCreatedCLIError(err UnityEndpointNotCreatedError, context ErrorContext) CLIError { + return CLIError{ + ErrorCode: ErrorCodeUnityNotReachable, + Phase: ErrorPhaseConnection, + Message: "The Unity CLI Loop server is not reachable for this project.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: context.ProjectRoot, + Command: context.Command, + NextActions: []string{ + "If Unity is closed, run `uloop launch`.", + "If Unity is starting, compiling, or reloading scripts, wait and retry.", + "Confirm that the command targets the intended Unity project.", + }, + Details: map[string]any{ + "EndpointDirectory": err.EndpointDirectory, + }, + } +} diff --git a/cli/common/errors/unity_endpoint_not_created_test.go b/cli/common/errors/unity_endpoint_not_created_test.go new file mode 100644 index 0000000000..d667e2b975 --- /dev/null +++ b/cli/common/errors/unity_endpoint_not_created_test.go @@ -0,0 +1,16 @@ +package clierrors + +import "testing" + +// Verifies a missing private endpoint directory uses the existing not-running recovery guidance. +func TestUnityEndpointNotCreatedErrorClassifiesAsUnityNotReachable(t *testing.T) { + err := UnityEndpointNotCreatedError{EndpointDirectory: "/tmp/uloop-501"} + cliErr := ClassifyError(err, ErrorContext{Command: "status"}) + + if cliErr.ErrorCode != ErrorCodeUnityNotReachable || cliErr.Phase != ErrorPhaseConnection { + t.Fatalf("unexpected classification: %#v", cliErr) + } + if len(cliErr.NextActions) == 0 || cliErr.NextActions[0] != "If Unity is closed, run `uloop launch`." { + t.Fatalf("missing launch guidance: %#v", cliErr.NextActions) + } +} diff --git a/cli/common/ipcendpoint/unity_endpoint_not_created.go b/cli/common/ipcendpoint/unity_endpoint_not_created.go new file mode 100644 index 0000000000..b1befb0790 --- /dev/null +++ b/cli/common/ipcendpoint/unity_endpoint_not_created.go @@ -0,0 +1,12 @@ +package ipcendpoint + +import "fmt" + +// UnityEndpointNotCreatedError reports that Unity has not created its private IPC directory yet. +type UnityEndpointNotCreatedError struct { + EndpointDirectory string +} + +func (err UnityEndpointNotCreatedError) Error() string { + return fmt.Sprintf("Unity IPC endpoint directory has not been created: %s", err.EndpointDirectory) +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security.go b/cli/common/ipcendpoint/unix_endpoint_security.go new file mode 100644 index 0000000000..f0abac26b3 --- /dev/null +++ b/cli/common/ipcendpoint/unix_endpoint_security.go @@ -0,0 +1,128 @@ +//go:build !windows + +package ipcendpoint + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +const ( + unixSocketParent = "/tmp" + unixPrivateDirectoryPermissions = uint32(0o700) + unixStickyBit = uint32(0o1000) +) + +type unixFileKind int + +const ( + unixFileKindOther unixFileKind = iota + unixFileKindDirectory + unixFileKindSymbolicLink + unixFileKindSocket +) + +type unixFileMetadata struct { + Kind unixFileKind + OwnerUserID uint32 + Permissions uint32 +} +type unixMetadataReader interface { + Lstat(string) (unixFileMetadata, error) + Stat(string) (unixFileMetadata, error) +} + +// Validate preserves the private Unix endpoint contract immediately before a client dials. +func Validate(network string, address string) error { + if network != "unix" { + return nil + } + return validateUnixEndpointPaths(unixSocketParent, filepath.Dir(address), uint32(os.Geteuid()), osUnixMetadataReader{}) +} + +func validateUnixEndpointPaths(parentPath string, endpointDirectoryPath string, effectiveUserID uint32, reader unixMetadataReader) error { + parentNoFollow, err := reader.Lstat(parentPath) + if err != nil { + return fmt.Errorf("inspect Unix endpoint parent without following links: %w", err) + } + if parentNoFollow.Kind != unixFileKindDirectory && parentNoFollow.Kind != unixFileKindSymbolicLink { + return fmt.Errorf("unix endpoint parent %s is neither a directory nor a symbolic link", parentPath) + } + parentFollowed, err := reader.Stat(parentPath) + if err != nil { + return fmt.Errorf("inspect resolved Unix endpoint parent: %w", err) + } + if parentFollowed.Kind != unixFileKindDirectory || parentFollowed.OwnerUserID != 0 || parentFollowed.Permissions&unixStickyBit == 0 { + return fmt.Errorf("resolved Unix endpoint parent %s must be a root-owned sticky directory", parentPath) + } + endpointDirectory, err := reader.Lstat(endpointDirectoryPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return UnityEndpointNotCreatedError{EndpointDirectory: endpointDirectoryPath} + } + return fmt.Errorf("inspect Unix endpoint directory: %w", err) + } + if endpointDirectory.Kind != unixFileKindDirectory { + return fmt.Errorf("unix endpoint directory %s must be a real directory", endpointDirectoryPath) + } + if endpointDirectory.OwnerUserID != effectiveUserID { + return fmt.Errorf("unix endpoint directory %s is not owned by the current user", endpointDirectoryPath) + } + if endpointDirectory.Permissions != unixPrivateDirectoryPermissions { + return fmt.Errorf("unix endpoint directory %s must have mode 0700", endpointDirectoryPath) + } + return nil +} + +type osUnixMetadataReader struct{} + +func (osUnixMetadataReader) Lstat(path string) (unixFileMetadata, error) { + info, err := os.Lstat(path) + if err != nil { + return unixFileMetadata{}, err + } + return unixMetadataFromFileInfo(info) +} + +func (osUnixMetadataReader) Stat(path string) (unixFileMetadata, error) { + info, err := os.Stat(path) + if err != nil { + return unixFileMetadata{}, err + } + return unixMetadataFromFileInfo(info) +} + +func unixMetadataFromFileInfo(info os.FileInfo) (unixFileMetadata, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return unixFileMetadata{}, fmt.Errorf("unix metadata for %s has unexpected type %T", info.Name(), info.Sys()) + } + permissions := uint32(info.Mode().Perm()) + if info.Mode()&os.ModeSetuid != 0 { + permissions |= 0o4000 + } + if info.Mode()&os.ModeSetgid != 0 { + permissions |= 0o2000 + } + if info.Mode()&os.ModeSticky != 0 { + permissions |= unixStickyBit + } + return unixFileMetadata{Kind: unixFileKindFromMode(info.Mode()), OwnerUserID: stat.Uid, Permissions: permissions}, nil +} + +func unixFileKindFromMode(mode os.FileMode) unixFileKind { + if mode&os.ModeSymlink != 0 { + return unixFileKindSymbolicLink + } + if mode.IsDir() { + return unixFileKindDirectory + } + if mode&os.ModeSocket != 0 { + return unixFileKindSocket + } + return unixFileKindOther +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security_test.go b/cli/common/ipcendpoint/unix_endpoint_security_test.go new file mode 100644 index 0000000000..04ff7b9143 --- /dev/null +++ b/cli/common/ipcendpoint/unix_endpoint_security_test.go @@ -0,0 +1,93 @@ +//go:build !windows + +package ipcendpoint + +import ( + "errors" + "io/fs" + "testing" +) + +const ( + testUnixParentPath = "/tmp" + testUnixEndpointPath = "/tmp/uloop-501" + testEffectiveUserID = uint32(501) +) + +// Verifies the contract permits a symlinked /tmp parent only when stat resolves it to root-owned sticky storage. +func TestValidateUnixEndpointPathsAllowsSecureSymlinkedParent(t *testing.T) { + reader := secureUnixMetadataReader() + reader.noFollow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindSymbolicLink, OwnerUserID: 0, Permissions: 0o777} + if err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader); err != nil { + t.Fatalf("expected secure parent: %v", err) + } +} + +// Verifies a missing private endpoint directory stays a typed not-running state. +func TestValidateUnixEndpointPathsReturnsTypedMissingEndpointError(t *testing.T) { + reader := secureUnixMetadataReader() + delete(reader.noFollow, testUnixEndpointPath) + err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader) + var missing UnityEndpointNotCreatedError + if !errors.As(err, &missing) { + t.Fatalf("expected typed missing endpoint, got %T: %v", err, err) + } +} + +// Verifies insecure parent and endpoint metadata fail closed. +func TestValidateUnixEndpointPathsRejectsInsecureFilesystemMetadata(t *testing.T) { + tests := []struct { + name string + configure func(*fakeUnixMetadataReader) + }{ + {"non sticky parent", func(r *fakeUnixMetadataReader) { + r.follow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: 0, Permissions: 0o777} + }}, + {"endpoint symlink", func(r *fakeUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindSymbolicLink, OwnerUserID: testEffectiveUserID, Permissions: 0o700} + }}, + {"wrong endpoint owner", func(r *fakeUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID + 1, Permissions: 0o700} + }}, + {"permissive endpoint mode", func(r *fakeUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID, Permissions: 0o770} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := secureUnixMetadataReader() + test.configure(reader) + if err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader); err == nil { + t.Fatal("expected security rejection") + } + }) + } +} + +type fakeUnixMetadataReader struct { + noFollow map[string]unixFileMetadata + follow map[string]unixFileMetadata +} + +func secureUnixMetadataReader() *fakeUnixMetadataReader { + return &fakeUnixMetadataReader{ + noFollow: map[string]unixFileMetadata{testUnixParentPath: {Kind: unixFileKindDirectory, OwnerUserID: 0, Permissions: 0o1777}, testUnixEndpointPath: {Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID, Permissions: 0o700}}, + follow: map[string]unixFileMetadata{testUnixParentPath: {Kind: unixFileKindDirectory, OwnerUserID: 0, Permissions: 0o1777}}, + } +} + +func (r *fakeUnixMetadataReader) Lstat(path string) (unixFileMetadata, error) { + v, ok := r.noFollow[path] + if !ok { + return unixFileMetadata{}, fs.ErrNotExist + } + return v, nil +} + +func (r *fakeUnixMetadataReader) Stat(path string) (unixFileMetadata, error) { + v, ok := r.follow[path] + if !ok { + return unixFileMetadata{}, errors.New("missing followed metadata") + } + return v, nil +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go b/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go new file mode 100644 index 0000000000..4b16596d59 --- /dev/null +++ b/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go @@ -0,0 +1,53 @@ +//go:build !windows + +package ipcendpoint + +import ( + "os" + "syscall" + "testing" + "time" +) + +// Verifies the live filesystem adapter reads the current user and exact private mode. +func TestOSUnixMetadataReaderReportsRealOwnerAndMode(t *testing.T) { + directory, err := os.MkdirTemp(unixSocketParent, "uloop-security-test-") + if err != nil { + t.Fatalf("create directory: %v", err) + } + t.Cleanup(func() { + if err := os.Remove(directory); err != nil { + t.Errorf("remove directory: %v", err) + } + }) + metadata, err := (osUnixMetadataReader{}).Lstat(directory) + if err != nil { + t.Fatalf("lstat directory: %v", err) + } + if metadata.Kind != unixFileKindDirectory || metadata.OwnerUserID != uint32(os.Geteuid()) || metadata.Permissions != unixPrivateDirectoryPermissions { + t.Fatalf("unexpected metadata: %#v", metadata) + } +} + +// Verifies set-ID bits remain visible to the exact 0700 policy comparison. +func TestUnixMetadataFromFileInfoPreservesSetIDBits(t *testing.T) { + metadata, err := unixMetadataFromFileInfo(fakeUnixFileInfo{mode: os.FileMode(0o700) | os.ModeDir | os.ModeSetuid | os.ModeSetgid, uid: uint32(os.Geteuid())}) + if err != nil { + t.Fatalf("convert metadata: %v", err) + } + if metadata.Permissions != 0o6700 { + t.Fatalf("expected set-ID bits, got %#o", metadata.Permissions) + } +} + +type fakeUnixFileInfo struct { + mode os.FileMode + uid uint32 +} + +func (f fakeUnixFileInfo) Name() string { return "endpoint" } +func (f fakeUnixFileInfo) Size() int64 { return 0 } +func (f fakeUnixFileInfo) Mode() os.FileMode { return f.mode } +func (f fakeUnixFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeUnixFileInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeUnixFileInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } diff --git a/cli/common/project/endpoint_contract_test.go b/cli/common/project/endpoint_contract_test.go index e5ecf28131..fa17e84fc7 100644 --- a/cli/common/project/endpoint_contract_test.go +++ b/cli/common/project/endpoint_contract_test.go @@ -4,22 +4,37 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "runtime" + "strconv" + "strings" "testing" ) const endpointContractPath = "tests/contracts/endpoint_contract.json" type endpointContractFile struct { - Comment string `json:"comment"` - Cases []endpointContractCase `json:"cases"` + Comment string `json:"comment"` + UnixSecurityPolicy endpointUnixSecurityPolicy `json:"unixSecurityPolicy"` + Cases []endpointContractCase `json:"cases"` +} + +type endpointUnixSecurityPolicy struct { + ParentPath string `json:"parentPath"` + ParentMayBeSymbolicLink bool `json:"parentMayBeSymbolicLink"` + ResolvedParentOwnerUID uint32 `json:"resolvedParentOwnerUid"` + ResolvedParentRequiresStickyBit bool `json:"resolvedParentRequiresStickyBit"` + EndpointDirectoryMayBeSymlink bool `json:"endpointDirectoryMayBeSymbolicLink"` + EndpointDirectoryMode string `json:"endpointDirectoryMode"` + EndpointDirectoryRejectedSpecialModes []string `json:"endpointDirectoryRejectedSpecialModes"` + SocketMode string `json:"socketMode"` } type endpointContractCase struct { ID string `json:"id"` CanonicalProjectRoot string `json:"canonicalProjectRoot"` TrimOnlyEquivalentRoots []string `json:"trimOnlyEquivalentRoots"` - UnixSocketPath string `json:"unixSocketPath"` + UnixSocketPathTemplate string `json:"unixSocketPathTemplate"` WindowsPipePath string `json:"windowsPipePath"` } @@ -49,7 +64,11 @@ func assertEndpointMatchesContract(t *testing.T, projectRoot string, contractCas t.Helper() endpoint := CreateEndpoint(projectRoot) - expected := contractCase.UnixSocketPath + expected := strings.ReplaceAll( + contractCase.UnixSocketPathTemplate, + "", + strconv.Itoa(os.Geteuid()), + ) if runtime.GOOS == "windows" { expected = contractCase.WindowsPipePath } @@ -58,6 +77,28 @@ func assertEndpointMatchesContract(t *testing.T, projectRoot string, contractCas } } +// Verifies the shared contract allows a symlinked parent while forbidding a symlinked endpoint directory. +func TestEndpointContractDefinesUnixSymlinkBoundary(t *testing.T) { + contract := readEndpointContract(t) + policy := contract.UnixSecurityPolicy + + if policy.ParentPath != "/tmp" || !policy.ParentMayBeSymbolicLink { + t.Fatalf("expected /tmp parent symlink allowance, got %#v", policy) + } + if policy.ResolvedParentOwnerUID != 0 || !policy.ResolvedParentRequiresStickyBit { + t.Fatalf("expected root-owned sticky resolved parent, got %#v", policy) + } + if policy.EndpointDirectoryMayBeSymlink { + t.Fatal("endpoint directory must reject symbolic links") + } + if policy.EndpointDirectoryMode != "0700" || policy.SocketMode != "0600" { + t.Fatalf("unexpected Unix endpoint modes: %#v", policy) + } + if !reflect.DeepEqual(policy.EndpointDirectoryRejectedSpecialModes, []string{"04700", "02700"}) { + t.Fatalf("expected set-ID modes to be rejected: %#v", policy) + } +} + func readEndpointContract(t *testing.T) endpointContractFile { t.Helper() diff --git a/cli/common/project/project.go b/cli/common/project/project.go index 870324a5d5..579e5ff43b 100644 --- a/cli/common/project/project.go +++ b/cli/common/project/project.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "sort" + "strconv" "strings" clierrors "github.com/hatayama/unity-cli-loop/common/errors" @@ -17,7 +18,8 @@ import ( const ( ipcEndpointPrefix = "UnityCliLoop" ipcHashLength = 16 - unixSocketDir = "/tmp/uloop" + unixSocketParent = "/tmp" + unixSocketPrefix = "uloop-" windowsPipePrefix = `\\.\pipe\uloop` ) @@ -33,6 +35,13 @@ var excludedProjectSearchDirs = map[string]bool{ } func ResolveConnection(startPath string, explicitProjectPath string) (unityipc.Connection, error) { + return resolveConnection(startPath, explicitProjectPath) +} + +func resolveConnection( + startPath string, + explicitProjectPath string, +) (unityipc.Connection, error) { projectRoot, err := resolveProjectRoot(startPath, explicitProjectPath) if err != nil { return unityipc.Connection{}, err @@ -61,7 +70,11 @@ func CreateEndpoint(canonicalProjectRoot string) unityipc.Endpoint { return unityipc.Endpoint{ Network: "unix", - Address: filepath.Join(unixSocketDir, endpointName+".sock"), + Address: filepath.Join( + unixSocketParent, + unixSocketPrefix+strconv.Itoa(os.Geteuid()), + endpointName+".sock", + ), } } diff --git a/cli/common/project/project_test.go b/cli/common/project/project_test.go index cf28b742e2..0b0da1ade7 100644 --- a/cli/common/project/project_test.go +++ b/cli/common/project/project_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "testing" @@ -22,7 +23,10 @@ func TestCreateEndpointUsesStableProjectHash(t *testing.T) { return } - expectedPrefix := filepath.Join("/tmp/uloop", "UnityCliLoop-") + expectedPrefix := filepath.Join( + "/tmp/uloop-"+strconv.Itoa(os.Geteuid()), + "UnityCliLoop-", + ) if !strings.HasPrefix(endpoint.Address, expectedPrefix) { t.Fatalf("unexpected unix endpoint: %s", endpoint.Address) } @@ -106,7 +110,7 @@ func TestResolveConnection_WhenSettingsFileIsMissing_ShouldUseProjectPathEndpoin projectRoot := t.TempDir() createUnityProject(t, projectRoot) - connection, err := ResolveConnection(projectRoot, "") + connection, err := resolveConnection(projectRoot, "") if err != nil { t.Fatalf("ResolveConnection failed: %v", err) } @@ -127,7 +131,7 @@ func TestResolveConnection_WhenSettingsFileContainsStaleRuntimeState_ShouldIgnor t.Fatalf("failed to write stale settings: %v", err) } - connection, err := ResolveConnection(projectRoot, "") + connection, err := resolveConnection(projectRoot, "") if err != nil { t.Fatalf("ResolveConnection failed: %v", err) } diff --git a/cli/common/unityipc/dial.go b/cli/common/unityipc/dial.go new file mode 100644 index 0000000000..9207ab942c --- /dev/null +++ b/cli/common/unityipc/dial.go @@ -0,0 +1,24 @@ +package unityipc + +import ( + "context" + "net" +) + +type ( + endpointDialer func(context.Context, Endpoint) (net.Conn, error) + endpointValidator func(Endpoint) error +) + +// dialEndpoint is the only client transport boundary. Why: project resolution is also +// used by commands that never connect, so security validation belongs immediately before dial. +func dialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { + return dialEndpointWithValidatorAndDialer(ctx, endpoint, validateEndpointSecurity, platformDialEndpoint) +} + +func dialEndpointWithValidatorAndDialer(ctx context.Context, endpoint Endpoint, validator endpointValidator, dialer endpointDialer) (net.Conn, error) { + if err := validator(endpoint); err != nil { + return nil, err + } + return dialer(ctx, endpoint) +} diff --git a/cli/common/unityipc/dial_test.go b/cli/common/unityipc/dial_test.go new file mode 100644 index 0000000000..c8a7fad34f --- /dev/null +++ b/cli/common/unityipc/dial_test.go @@ -0,0 +1,29 @@ +package unityipc + +import ( + "context" + "errors" + "net" + "testing" +) + +// Verifies failed endpoint validation prevents every network dial attempt. +func TestDialEndpointWithValidatorAndDialerSkipsDialWhenValidationFails(t *testing.T) { + validationErr := errors.New("endpoint is insecure") + dialAttempts := 0 + _, err := dialEndpointWithValidatorAndDialer( + context.Background(), + Endpoint{Network: "unix", Address: "/tmp/uloop-501/test.sock"}, + func(Endpoint) error { return validationErr }, + func(context.Context, Endpoint) (net.Conn, error) { + dialAttempts++ + return nil, errors.New("must not dial") + }, + ) + if !errors.Is(err, validationErr) { + t.Fatalf("expected validation error, got %v", err) + } + if dialAttempts != 0 { + t.Fatalf("expected no dial attempts, got %d", dialAttempts) + } +} diff --git a/cli/common/unityipc/dial_unix.go b/cli/common/unityipc/dial_unix.go index 257ba27d9b..eea57d2a37 100644 --- a/cli/common/unityipc/dial_unix.go +++ b/cli/common/unityipc/dial_unix.go @@ -7,7 +7,7 @@ import ( "net" ) -func dialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { +func platformDialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { dialer := net.Dialer{} return dialer.DialContext(ctx, endpoint.Network, endpoint.Address) } diff --git a/cli/common/unityipc/dial_windows.go b/cli/common/unityipc/dial_windows.go index 1660af1b72..07db3ef4e9 100644 --- a/cli/common/unityipc/dial_windows.go +++ b/cli/common/unityipc/dial_windows.go @@ -9,6 +9,6 @@ import ( "github.com/Microsoft/go-winio" ) -func dialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { +func platformDialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { return winio.DialPipeContext(ctx, endpoint.Address) } diff --git a/cli/common/unityipc/unix_endpoint_security_unix.go b/cli/common/unityipc/unix_endpoint_security_unix.go new file mode 100644 index 0000000000..4fb8a27781 --- /dev/null +++ b/cli/common/unityipc/unix_endpoint_security_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package unityipc + +import ( + "github.com/hatayama/unity-cli-loop/common/ipcendpoint" +) + +func validateEndpointSecurity(endpoint Endpoint) error { + return ipcendpoint.Validate(endpoint.Network, endpoint.Address) +} diff --git a/cli/common/unityipc/unix_endpoint_security_windows.go b/cli/common/unityipc/unix_endpoint_security_windows.go new file mode 100644 index 0000000000..37bbd832e5 --- /dev/null +++ b/cli/common/unityipc/unix_endpoint_security_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package unityipc + +// Why: named pipes do not use the Unix filesystem endpoint boundary; the shared dial +// choke point deliberately keeps Windows transport behavior unchanged. +func validateEndpointSecurity(endpoint Endpoint) error { + return nil +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index f9ae168f14..8db78cd3c4 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -1008,6 +1008,19 @@ func TestDispatcherReleaseAssetNameRejectsUnsupportedPlatform(t *testing.T) { } } +func TestResolveDispatcherProjectRootLaunchDoesNotRequireExistingEndpointDirectory(t *testing.T) { + // Verifies first launch resolves the Unity project before its private IPC directory exists. + projectRoot := createDispatcherUnityProject(t) + + resolved, err := resolveDispatcherProjectRoot(projectRoot, "", []string{"launch"}) + if err != nil { + t.Fatalf("first launch project resolution failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("unexpected launch project root: got %s want %s", resolved, projectRoot) + } +} + func createDispatcherUnityProject(t *testing.T) string { t.Helper() projectRoot := t.TempDir() diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 1c7bb15dbc..d9887930bd 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "9015856915596b7c6e58992664ccf8996e8d827e" + "sharedInputsHash": "d59d3b093b55893ccd07b5906f02658f23030bc5" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 38681b53cd..0f27802f88 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "c25f44bebdff747f69e1e6c653a4d8d9a5fc7c4f" + "sharedInputsHash": "1d0b11deca98e51fd880037a967a21e881619ed7" } diff --git a/cli/release-automation/internal/automation/release_trigger_guard.go b/cli/release-automation/internal/automation/release_trigger_guard.go index 8e58e06d78..3e60059b79 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard.go +++ b/cli/release-automation/internal/automation/release_trigger_guard.go @@ -153,6 +153,7 @@ var sharedCommonPackageRoots = []string{ "cli/common/clicontract/", "cli/common/clicore/", "cli/common/errors/", + "cli/common/ipcendpoint/", "cli/common/progress/", "cli/common/project/", "cli/common/skillscan/", diff --git a/scripts/stamp-release-inputs.sh b/scripts/stamp-release-inputs.sh index 1c79d9386c..4795ef7706 100755 --- a/scripts/stamp-release-inputs.sh +++ b/scripts/stamp-release-inputs.sh @@ -20,6 +20,7 @@ list_shared_common_inputs() { 'cli/common/clicontract/' \ 'cli/common/clicore/' \ 'cli/common/errors/' \ + 'cli/common/ipcendpoint/' \ 'cli/common/progress/' \ 'cli/common/project/' \ 'cli/common/skillscan/' \ diff --git a/tests/contracts/endpoint_contract.json b/tests/contracts/endpoint_contract.json index ef91b80ac7..2ab37e6b4f 100644 --- a/tests/contracts/endpoint_contract.json +++ b/tests/contracts/endpoint_contract.json @@ -1,5 +1,15 @@ { - "comment": "Maps already-canonicalized project roots to IPC endpoint names. Symlink/realpath resolution (Go filepath.EvalSymlinks / C# realpath) is intentionally out of scope because it depends on the live filesystem. Each canonicalProjectRoot is the post-resolution string that both sides hash. trimOnlyEquivalentRoots are trailing-separator variants that must hash identically after separator trimming only.", + "comment": "Maps already-canonicalized project roots to IPC endpoint names. Symlink/realpath resolution of the project root is intentionally out of scope because it depends on the live filesystem. Unix socket strings keep the unresolved /tmp spelling and replace with the effective user ID. The /tmp parent may itself be a symlink, but its followed target must be a root-owned sticky directory; the endpoint directory must never be a symlink.", + "unixSecurityPolicy": { + "parentPath": "/tmp", + "parentMayBeSymbolicLink": true, + "resolvedParentOwnerUid": 0, + "resolvedParentRequiresStickyBit": true, + "endpointDirectoryMayBeSymbolicLink": false, + "endpointDirectoryMode": "0700", + "endpointDirectoryRejectedSpecialModes": ["04700", "02700"], + "socketMode": "0600" + }, "cases": [ { "id": "unix-stable", @@ -7,14 +17,14 @@ "trimOnlyEquivalentRoots": [ "/Users/example/MyProject/" ], - "unixSocketPath": "/tmp/uloop/UnityCliLoop-bebe6bce5923715b.sock", + "unixSocketPathTemplate": "/tmp/uloop-/UnityCliLoop-bebe6bce5923715b.sock", "windowsPipePath": "\\\\.\\pipe\\uloop-UnityCliLoop-bebe6bce5923715b" }, { "id": "unix-case-sensitive", "canonicalProjectRoot": "/Users/example/myproject", "trimOnlyEquivalentRoots": [], - "unixSocketPath": "/tmp/uloop/UnityCliLoop-6b4ae0bfeab42b19.sock", + "unixSocketPathTemplate": "/tmp/uloop-/UnityCliLoop-6b4ae0bfeab42b19.sock", "windowsPipePath": "\\\\.\\pipe\\uloop-UnityCliLoop-6b4ae0bfeab42b19" }, { @@ -23,7 +33,7 @@ "trimOnlyEquivalentRoots": [ "C:\\Users\\example\\MyProject\\" ], - "unixSocketPath": "/tmp/uloop/UnityCliLoop-4abce6d9d9e51244.sock", + "unixSocketPathTemplate": "/tmp/uloop-/UnityCliLoop-4abce6d9d9e51244.sock", "windowsPipePath": "\\\\.\\pipe\\uloop-UnityCliLoop-4abce6d9d9e51244" } ]