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"
}
]