Skip to content

Commit fcb81cd

Browse files
authored
feat(auth): Unity plugin dual-hash metadata + B10 port derivation (d1) (#905)
Vendored the c1 dual-hash McpPlugin dev-DLL into Unity + fixed B10 port derivation to use ProjectIdentity v2 normalization; added dual-hash + port-normalization unit tests. B11 E2E repro deferred to c2/k2. Closes #904
1 parent ba9f528 commit fcb81cd

16 files changed

Lines changed: 151 additions & 22 deletions

File tree

0 Bytes
Binary file not shown.
1 KB
Binary file not shown.

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Runtime/UnityMcpPlugin.cs

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using System.Threading;
1515
using System.Threading.Tasks;
1616
using com.IvanMurzak.McpPlugin;
17+
using com.IvanMurzak.McpPlugin.AgentConfig;
1718
using com.IvanMurzak.McpPlugin.Common.Model;
1819
using com.IvanMurzak.ReflectorNet;
1920
using Microsoft.AspNetCore.SignalR.Client;
@@ -246,30 +247,30 @@ public static string GenerateToken()
246247
}
247248

248249
/// <summary>
249-
/// Generate a deterministic TCP port based on current directory.
250-
/// Uses SHA256 hash for better distribution and less collisions.
251-
/// Port range: 20000-29999 (avoids Windows ephemeral/reserved port ranges).
250+
/// Generate a deterministic TCP port for the current working directory
251+
/// (<see cref="Environment.CurrentDirectory"/>). Port range: 20000-29999 (avoids Windows
252+
/// ephemeral/reserved port ranges).
252253
/// </summary>
254+
/// <remarks>
255+
/// Defect <b>B10</b> fix (auth-fixes d1): the derivation runs the directory through the shared
256+
/// <see cref="ProjectIdentity"/> <b>v2</b> normalization (trim trailing separators, convert
257+
/// <c>'\\'</c> to <c>'/'</c>, then <see cref="string.ToLowerInvariant"/>) via
258+
/// <see cref="ProjectIdentity.DerivePortV2"/> — instead of hashing the raw, untrimmed, un-separator-
259+
/// normalized <see cref="Environment.CurrentDirectory"/> string it used before. This keeps the
260+
/// local port in lock-step with the routing pin (also v2-normalized), so a Windows working
261+
/// directory reported with backslashes hashes identically to its forward-slash form. The port
262+
/// byte-math (first 4 hash bytes, little-endian, mapped into 20000-29999) is unchanged, so a
263+
/// path with no backslashes and no trailing separator yields the same port as before.
264+
/// </remarks>
253265
public static int GeneratePortFromDirectory()
254-
{
255-
const int MinPort = 20000; // Range chosen to avoid Windows ephemeral/reserved ports (49152-65535)
256-
const int MaxPort = 29999;
257-
const int PortRange = MaxPort - MinPort + 1;
258-
259-
var currentDir = Environment.CurrentDirectory.ToLowerInvariant();
260-
261-
using (var sha256 = System.Security.Cryptography.SHA256.Create())
262-
{
263-
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(currentDir));
264-
265-
// Use first 4 bytes as an unsigned integer to avoid Math.Abs(int.MinValue) overflow
266-
var hash = (uint)BitConverter.ToInt32(hashBytes, 0);
266+
=> GeneratePortFromDirectory(Environment.CurrentDirectory);
267267

268-
// Map to port range
269-
var port = MinPort + (int)(hash % PortRange);
270-
271-
return port;
272-
}
273-
}
268+
/// <summary>
269+
/// Deterministic TCP port for an explicit <paramref name="directory"/>, derived via the shared
270+
/// <see cref="ProjectIdentity"/> v2 normalization (see <see cref="GeneratePortFromDirectory()"/>).
271+
/// Exposed so the derivation is unit-testable independent of the process working directory.
272+
/// </summary>
273+
public static int GeneratePortFromDirectory(string directory)
274+
=> ProjectIdentity.DerivePortV2(directory);
274275
}
275276
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
┌──────────────────────────────────────────────────────────────────┐
3+
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4+
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5+
│ Copyright (c) 2025 Ivan Murzak │
6+
│ Licensed under the Apache License, Version 2.0. │
7+
│ See the LICENSE file in the project root for more information. │
8+
└──────────────────────────────────────────────────────────────────┘
9+
*/
10+
11+
#nullable enable
12+
using System;
13+
using com.IvanMurzak.McpPlugin.AgentConfig;
14+
using NUnit.Framework;
15+
16+
namespace com.IvanMurzak.Unity.MCP.Editor.Tests
17+
{
18+
/// <summary>
19+
/// Defect <b>B10</b> fix (auth-fixes d1): <see cref="UnityMcpPlugin.GeneratePortFromDirectory(string)"/>
20+
/// now derives the deterministic local port through the shared <see cref="ProjectIdentity"/> <b>v2</b>
21+
/// normalization (trim trailing separators, <c>'\\'</c> → <c>'/'</c>, <see cref="string.ToLowerInvariant"/>)
22+
/// instead of hashing the raw, untrimmed working-directory string. That keeps the port in lock-step with
23+
/// the routing pin, so a Windows working directory reported with backslashes maps to the SAME port as its
24+
/// forward-slash form. Expected values are the committed v2 golden vectors
25+
/// (<c>MCP-Plugin-dotnet/McpPlugin/src/AgentConfig/ProjectIdentity.GoldenVectors.v2.json</c>).
26+
/// </summary>
27+
public class PortDerivationV2Tests
28+
{
29+
[Test]
30+
public void GeneratePortFromDirectory_DelegatesToProjectIdentityV2()
31+
{
32+
const string dir = @"C:\Users\user\my-game";
33+
Assert.AreEqual(ProjectIdentity.DerivePortV2(dir), UnityMcpPlugin.GeneratePortFromDirectory(dir));
34+
}
35+
36+
// v2 golden vectors: backslash and forward-slash forms of the same Windows root converge (the B5/B10
37+
// fix); a trailing separator is trimmed; a POSIX path is unaffected (v2 == v1 there).
38+
[TestCase(@"C:\Users\user\my-game", 24298)] // Windows backslash form
39+
[TestCase(@"C:\Users\user\my-game\", 24298)] // trailing backslash trimmed → identical
40+
[TestCase("C:/Users/user/my-game", 24298)] // forward-slash form → SAME port under v2
41+
[TestCase("C:/Users/user/my-game/", 24298)] // trailing slash trimmed → identical
42+
[TestCase("/home/user/my-game", 23940)] // POSIX typical → v2 == v1
43+
[TestCase("/home/user/my-game/", 23940)] // trailing slash trimmed → identical
44+
public void GeneratePortFromDirectory_MatchesV2GoldenPort(string dir, int expectedPort)
45+
{
46+
Assert.AreEqual(expectedPort, UnityMcpPlugin.GeneratePortFromDirectory(dir));
47+
}
48+
49+
[Test]
50+
public void GeneratePortFromDirectory_BackslashAndForwardSlash_Converge()
51+
{
52+
// The defining B10/B5 property: the two separator forms of one root yield the SAME port,
53+
// whereas the pre-fix raw-string derivation gave two different ports on Windows.
54+
Assert.AreEqual(
55+
UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"),
56+
UnityMcpPlugin.GeneratePortFromDirectory("C:/Users/user/my-game"));
57+
}
58+
59+
[Test]
60+
public void GeneratePortFromDirectory_TrailingSeparatorInsensitive()
61+
{
62+
Assert.AreEqual(
63+
UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game"),
64+
UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game\"));
65+
}
66+
67+
[Test]
68+
public void GeneratePortFromDirectory_StaysInDeterministicRange()
69+
{
70+
var port = UnityMcpPlugin.GeneratePortFromDirectory(@"C:\Users\user\my-game");
71+
Assert.GreaterOrEqual(port, ProjectIdentity.MinPort);
72+
Assert.LessOrEqual(port, ProjectIdentity.MaxPort);
73+
}
74+
75+
[Test]
76+
public void GeneratePortFromDirectory_NullDirectory_Throws()
77+
{
78+
Assert.Throws<ArgumentNullException>(() => UnityMcpPlugin.GeneratePortFromDirectory(null!));
79+
}
80+
}
81+
}

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/PortDerivationV2Tests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/Tests/Editor/Auth/ProjectInstanceServiceTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using System.IO;
1414
using com.IvanMurzak.McpPlugin;
1515
using com.IvanMurzak.McpPlugin.AgentConfig;
16+
using com.IvanMurzak.McpPlugin.Common;
1617
using com.IvanMurzak.Unity.MCP.Editor.Services;
1718
using NUnit.Framework;
1819

@@ -60,6 +61,41 @@ public void BuildMetadata_ProducesUnityHandshakePayload_WithPinAsHashPrefix()
6061
StringAssert.StartsWith(ProjectIdentity.DerivePin(ProjectRoot), metadata.ProjectPathHash);
6162
}
6263

64+
[Test]
65+
public void BuildMetadata_SendsDualHash_V2PrimaryAndLegacyV1()
66+
{
67+
// A Windows-style backslash root is the case where the v1 and v2 normalizations diverge
68+
// (v2 converts '\' -> '/', v1 does not), so both hashes must be present and distinct — this
69+
// is exactly the dual-hash transition (auth-fixes T3 / defect B5) that lets a session pinned
70+
// by an OLD (v1-pin) config still match this NEW plugin.
71+
const string winRoot = @"C:\Users\dev\MyGame";
72+
var metadata = ProjectInstanceService.BuildMetadata(winRoot, "MyGame", instanceId: "sess-dh");
73+
74+
// Primary hash = v2 (separator-normalized); legacy hash = v1 (separators NOT normalized).
75+
Assert.AreEqual(ProjectIdentity.DeriveProjectPathHashV2(winRoot), metadata.ProjectPathHash);
76+
Assert.AreEqual(ProjectIdentity.DeriveProjectPathHash(winRoot), metadata.ProjectPathHashLegacy);
77+
Assert.AreEqual(64, metadata.ProjectPathHash.Length);
78+
Assert.AreEqual(64, metadata.ProjectPathHashLegacy.Length);
79+
Assert.AreNotEqual(metadata.ProjectPathHash, metadata.ProjectPathHashLegacy,
80+
"on a Windows backslash root the v2 and v1 hashes must diverge, proving both are sent");
81+
82+
// The v2 routing pin is a prefix of the primary hash (server pin-matches by prefix).
83+
StringAssert.StartsWith(ProjectIdentity.DerivePinV2(winRoot), metadata.ProjectPathHash);
84+
}
85+
86+
[Test]
87+
public void BuildMetadata_ToQuery_CarriesBothHashKeys()
88+
{
89+
const string winRoot = @"C:\Users\dev\MyGame";
90+
var metadata = ProjectInstanceService.BuildMetadata(winRoot, "MyGame", instanceId: "sess-dh2");
91+
92+
var query = metadata.ToQuery();
93+
94+
// Both hashes travel as hub-handshake query parameters (dual-hash visible in the handshake).
95+
Assert.AreEqual(metadata.ProjectPathHash, query[Consts.MCP.Server.HubQuery.ProjectPathHash]);
96+
Assert.AreEqual(metadata.ProjectPathHashLegacy, query[Consts.MCP.Server.HubQuery.ProjectPathHashLegacy]);
97+
}
98+
6399
[Test]
64100
public void BuildMetadata_DefaultsInstanceId_ToSessionInstanceId()
65101
{
Binary file not shown.
1 KB
Binary file not shown.
Binary file not shown.
1 KB
Binary file not shown.

0 commit comments

Comments
 (0)