/*
+------------------------------------------------------------------+
| Author: Ivan Murzak (https://github.com/IvanMurzak) |
| Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) |
| Copyright (c) 2025 Ivan Murzak |
| Licensed under the Apache License, Version 2.0. |
| See the LICENSE file in the project root for more information. |
+------------------------------------------------------------------+
*/
#nullable enable
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver
{
///
/// Windows-only pre-flight check that aborts a NuGet install before any DLL
/// is written to disk if the resulting path would exceed the legacy
/// MAX_PATH limit (260 chars).
///
/// Why this is necessary: Unity's bundled Mono runtime opens NuGet DLLs via
/// System.IO.FileStream → legacy CreateFileW *without* the
/// \\?\ long-path prefix. Even with Windows' "Enable Win32 long
/// paths" group policy enabled, Unity's runtime does not honor it, so a DLL
/// at a long path appears "not found" to AssemblyValidation with an
/// opaque DirectoryNotFoundException stack trace.
///
/// We surface the error pre-extraction so the user gets an actionable
/// message instead of a confusing post-hoc validation failure (see issue
/// #733).
///
/// macOS / Linux: this check is a no-op. Linux's PATH_MAX is much
/// higher (~4096) and macOS handles long paths transparently for our
/// purposes.
///
static class NuGetLongPathPreflight
{
const string Tag = NuGetConfig.LogTag;
///
/// Threshold below the OS hard limit. The companion .meta file
/// Unity creates next to each DLL adds ".meta" = 5 chars to the
/// path, so we leave 5 chars of slack — meaning a DLL whose full path
/// is exactly 255 chars is allowed; one at 256 chars is rejected.
///
internal const int MaxPath = 260;
internal const int MetaSuffixSlack = 5;
internal const int DefaultMaxAllowedPathLength = MaxPath - MetaSuffixSlack;
///
/// Runs the pre-flight check against a single planned DLL path. Throws
/// when the path would exceed
/// the threshold on Windows.
///
/// On non-Windows platforms this method returns without throwing.
///
public static void Check(string plannedDllPath, string packageId)
{
CheckWith(plannedDllPath, packageId, IsWindows(), DefaultMaxAllowedPathLength);
}
///
/// Test seam: same as but with the OS check and the
/// threshold injected. Lets EditMode tests exercise the rejection path
/// deterministically without needing a real 250-char temp directory.
///
internal static void CheckWith(string plannedDllPath, string packageId, bool isWindows, int maxAllowedPathLength)
{
if (!isWindows)
return;
// We compare against the absolute path the OS will see, not the
// (possibly project-relative) path the caller passed in, because
// CreateFileW operates on the absolute form.
//
// Path.GetFullPath itself can throw PathTooLongException on legacy
// .NET Framework / Mono when the resolved path exceeds the runtime's
// own buffer ceiling — convert that into the same actionable
// InstallPathTooLongException so callers see one consistent error
// class instead of falling into the generic catch (Exception) higher
// up the stack.
string absolutePath;
try
{
absolutePath = Path.GetFullPath(plannedDllPath);
}
catch (PathTooLongException ex)
{
throw new InstallPathTooLongException(
BuildLongPathMessage(packageId, plannedDllPath, plannedDllPath.Length, ex.Message),
plannedDllPath,
plannedDllPath.Length);
}
if (absolutePath.Length <= maxAllowedPathLength)
return;
throw new InstallPathTooLongException(
BuildLongPathMessage(packageId, absolutePath, absolutePath.Length, innerDetail: null),
absolutePath,
absolutePath.Length);
}
static string BuildLongPathMessage(string packageId, string path, int pathLength, string? innerDetail)
{
var detail = innerDetail == null
? string.Empty
: $"\n\nUnderlying error: {innerDetail}";
return
$"{Tag} Cannot install '{packageId}' — the DLL plus its Unity .meta companion would exceed " +
$"Windows' 260-character path limit (the .meta adds {MetaSuffixSlack} chars, so DLLs are " +
$"capped at {DefaultMaxAllowedPathLength} chars):\n\n" +
$" {path} ({pathLength} chars; max {DefaultMaxAllowedPathLength})\n\n" +
"Unity's bundled Mono runtime (used by the assembly validator) does NOT honor Windows' " +
"\"Enable Win32 long paths\" registry/group-policy setting, so DLLs at long paths appear " +
"missing even though they are on disk. This is a known limitation of Unity, not of this plugin.\n\n" +
"Move your Unity project to a shorter path and reopen it. For example:\n" +
$" C:\\src\\\\\n\n" +
$"Project root currently: {TryGetProjectRoot()}" + detail;
}
static string TryGetProjectRoot()
{
try
{
// Application.dataPath is "/Assets". Its parent is the project root.
var dataPath = UnityEngine.Application.dataPath;
return Path.GetFullPath(Path.Combine(dataPath, ".."));
}
catch
{
return "(unable to resolve project root)";
}
}
static bool IsWindows() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
}
///
/// Raised by when the planned
/// install path would exceed the Windows MAX_PATH limit.
///
/// The full Message is also surfaced via Debug.LogError by
/// the caller so the user sees it in the Unity Console without expanding
/// the stack trace.
///
sealed class InstallPathTooLongException : Exception
{
public string PlannedPath { get; }
public int PlannedPathLength { get; }
public InstallPathTooLongException(string message, string plannedPath, int plannedPathLength)
: base(message)
{
PlannedPath = plannedPath;
PlannedPathLength = plannedPathLength;
}
}
}