diff --git a/source/Reloaded.Mod.Installer.Lib/Settings.cs b/source/Reloaded.Mod.Installer.Lib/Settings.cs index 8c253c2b..52b6e6fe 100644 --- a/source/Reloaded.Mod.Installer.Lib/Settings.cs +++ b/source/Reloaded.Mod.Installer.Lib/Settings.cs @@ -35,8 +35,7 @@ public static Settings GetSettings(string[] args) private static string GetSafeInstallPath() { var installPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - bool hasNonAsciiChars = installPath.Any(c => c > 127); - if (installPath.Contains("OneDrive") || hasNonAsciiChars) + if (IsPathInCloudSyncFolder(installPath)) { var driveRoot = Path.GetPathRoot(Environment.SystemDirectory); if (driveRoot == null) @@ -46,4 +45,99 @@ private static string GetSafeInstallPath() } return installPath; } + + /// + /// Checks whether a given path is inside a cloud sync folder (OneDrive, Dropbox, Google Drive, + /// iCloud, Box, MEGA, etc.). Reloaded installed into such folders is avoided because many mods + /// do not tolerate cloud offload/locking, and load times are poor. + /// + /// Detection is layered, checked in order: + /// + /// - OneDrive environment variables (OneDrive / OneDriveCommercial). + /// + /// - The Windows Cloud Files API (CfGetSyncRootInfoByPath, available on Windows 10 1709+), + /// which detects any provider that registers a sync root. + /// + /// Desktop is only ever redirected by OneDrive, so these two tiers are sufficient for the install-path check. + /// + private static bool IsPathInCloudSyncFolder(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + string fullPath; + try { fullPath = Path.GetFullPath(path); } + catch { return false; } + + return IsInOneDrive(fullPath) + || IsInRegisteredCloudSyncRoot(fullPath); + } + + // --- Tier 1: OneDrive environment variables --- + private static bool IsInOneDrive(string fullPath) + { + foreach (var envVar in s_oneDriveEnvVars) + { + var root = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrEmpty(root)) + continue; + + try + { + var fullRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (IsUnder(fullPath, fullRoot)) + return true; + } + catch { /* malformed path/env - skip */ } + } + + return false; + } + + // --- Tier 2: Cloud Files API (cldapi.dll), Windows 10 1709+ --- + // A single native call reports whether the path is inside any registered cloud sync root, + // regardless of provider. No ancestor walk or hydration-state dependence. + private static bool IsInRegisteredCloudSyncRoot(string fullPath) + { + try + { + // CF_SYNC_ROOT_INFO_CLASS.CfSyncRootInfoBasic == 0. + // A small buffer is plenty for the basic info struct; we only care about the HRESULT. + var buffer = new byte[256]; + int hr = CfGetSyncRootInfoByPath(fullPath, 0, buffer, buffer.Length, out _); + return hr >= 0; // S_OK (0) => the path resolves to a registered sync root. + } + catch + { + // cldapi.dll missing (older than Windows 10 1709) or call failed: rely on other tiers. + return false; + } + } + + /// + /// True if equals or is a descendant of . + /// Case-insensitive. The separator is appended on the prefix check so a root named + /// e.g. "OneDrive" does not match a sibling like "OneDriveBackup". + /// + private static bool IsUnder(string fullPath, string root) + { + var trimmedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (trimmedRoot.Length == 0) + return false; + + return fullPath.Equals(trimmedRoot, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(trimmedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + [DllImport("cldapi.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.I4)] + private static extern int CfGetSyncRootInfoByPath( + string filePath, + int infoClass, + [Out] byte[] infoBuffer, + int infoBufferSize, + out int returnedLength); + + private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" }; } \ No newline at end of file diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs index da7a1041..638d2acc 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs @@ -62,9 +62,9 @@ static string GetProductName(string exePath) try { exePath = SymlinkResolver.GetFinalPathName(exePath); } catch (Exception e) { Errors.HandleException(e, Resources.ErrorAddApplicationCantReadSymlink.Get()); } - // Warn if OneDrive or NonAsciiChars detected in Game Path + // Warn if the Game Path is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars bool hasNonAsciiChars = exePath.Any(c => c > 127); - if (exePath.Contains("OneDrive") || hasNonAsciiChars) + if (PathUtility.IsPathInCloudSyncFolder(exePath) || hasNonAsciiChars) { var confirmAddAnyway = Actions.DisplayMessagebox.Invoke(Resources.ProblematicPathTitle.Get(), Resources.ProblematicPathAppDescription.Get(), new Actions.DisplayMessageBoxParams() { diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs new file mode 100644 index 00000000..b31332ea --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs @@ -0,0 +1,145 @@ +using System.Runtime.InteropServices; +using Environment = System.Environment; + +namespace Reloaded.Mod.Launcher.Lib.Utility; + +/// +/// Utility methods for inspecting file system paths. +/// +public static class PathUtility +{ + /// + /// Checks whether a given path is inside a cloud sync folder (OneDrive, Dropbox, Google Drive, + /// iCloud, Box, MEGA, etc.). Reloaded and games inside such folders are avoided because many mods + /// do not tolerate cloud offload/locking, and load times are poor. + /// + /// Detection is layered, checked in order: + /// + /// - OneDrive environment variables (OneDrive / OneDriveCommercial). + /// + /// - The Windows Cloud Files API (CfGetSyncRootInfoByPath, available on Windows 10 1709+), + /// which detects any provider that registers a sync root (OneDrive, Dropbox, iCloud, Box, ...). + /// + /// - Known cloud folder names under the user profile, as a fallback for older systems or + /// providers that do not register a sync root. + /// + /// The path to check. + /// True if the path is inside a cloud-synced folder; false otherwise. + public static bool IsPathInCloudSyncFolder(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + string fullPath; + try { fullPath = Path.GetFullPath(path); } + catch { return false; } + + return IsInOneDrive(fullPath) + || IsInRegisteredCloudSyncRoot(fullPath) + || IsInKnownCloudFolder(fullPath); + } + + // --- Tier 1: OneDrive environment variables --- + private static bool IsInOneDrive(string fullPath) + { + foreach (var envVar in s_oneDriveEnvVars) + { + var root = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrEmpty(root)) + continue; + + try + { + var fullRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (IsUnder(fullPath, fullRoot)) + return true; + } + catch { /* malformed path/env - skip */ } + } + + return false; + } + + // --- Tier 2: Cloud Files API (cldapi.dll), Windows 10 1709+ --- + // A single native call reports whether the path is inside any registered cloud sync root, + // regardless of provider. No ancestor walk or hydration-state dependence. + private static bool IsInRegisteredCloudSyncRoot(string fullPath) + { + try + { + // CF_SYNC_ROOT_INFO_CLASS.CfSyncRootInfoBasic == 0. + // A small buffer is plenty for the basic info struct; we only care about the HRESULT. + var buffer = new byte[256]; + int hr = CfGetSyncRootInfoByPath(fullPath, 0, buffer, buffer.Length, out _); + return hr >= 0; // S_OK (0) => the path resolves to a registered sync root. + } + catch + { + // cldapi.dll missing (older than Windows 10 1709) or call failed: rely on other tiers. + return false; + } + } + + // --- Tier 3: known cloud folder names under the user profile --- + private static bool IsInKnownCloudFolder(string fullPath) + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(userProfile)) + return false; + + string fullProfile; + try { fullProfile = Path.GetFullPath(userProfile).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + catch { return false; } + + foreach (var folder in s_knownCloudFolders) + { + try + { + if (IsUnder(fullPath, Path.Combine(fullProfile, folder))) + return true; + } + catch { /* malformed name - skip */ } + } + + return false; + } + + /// + /// True if equals or is a descendant of . + /// Case-insensitive. The separator is appended on the prefix check so a root named + /// e.g. "OneDrive" does not match a sibling like "OneDriveBackup". + /// + private static bool IsUnder(string fullPath, string root) + { + var trimmedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (trimmedRoot.Length == 0) + return false; + + return fullPath.Equals(trimmedRoot, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(trimmedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + [DllImport("cldapi.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.I4)] + private static extern int CfGetSyncRootInfoByPath( + string filePath, + int infoClass, + [Out] byte[] infoBuffer, + int infoBufferSize, + out int returnedLength); + + private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" }; + + private static readonly string[] s_knownCloudFolders = + { + "Dropbox", + "Google Drive", + "GoogleDrive", + "iCloudDrive", + "Box Sync", + "Box", + "MEGA", + "pCloud" + }; +} diff --git a/source/Reloaded.Mod.Launcher/App.xaml.cs b/source/Reloaded.Mod.Launcher/App.xaml.cs index 3478489d..1df2724c 100644 --- a/source/Reloaded.Mod.Launcher/App.xaml.cs +++ b/source/Reloaded.Mod.Launcher/App.xaml.cs @@ -42,9 +42,9 @@ private void OnStartup(object sender, StartupEventArgs e) // Need to construct MainWindow before invoking any dialog, otherwise Shutdown will be called on closing the dialog var window = new MainWindow(); - // Warn if OneDrive or NonAsciiChars detected in Reloaded-II directory + // Warn if the Reloaded-II directory is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars bool reloadedPathHasNonAsciiChars = AppContext.BaseDirectory.Any(c => c > 127); - if (AppContext.BaseDirectory.Contains("OneDrive") || reloadedPathHasNonAsciiChars) + if (PathUtility.IsPathInCloudSyncFolder(AppContext.BaseDirectory) || reloadedPathHasNonAsciiChars) { Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathReloadedDescription.Get(), new Actions.DisplayMessageBoxParams() { @@ -54,12 +54,12 @@ private void OnStartup(object sender, StartupEventArgs e) } else // We only do this check if the Reloaded-II directory check passed { - // Warn if OneDrive or NonAsciiChars detected in Mods directory + // Warn if the Mods directory is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars var modsDirectory = Lib.IoC.Get().GetModConfigDirectory(); if (modsDirectory != null) { bool modsDirectoryPathHasNonAsciiChars = modsDirectory.Any(c => c > 127); - if (modsDirectory.Contains("OneDrive") || modsDirectoryPathHasNonAsciiChars) + if (PathUtility.IsPathInCloudSyncFolder(modsDirectory) || modsDirectoryPathHasNonAsciiChars) { Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathModsDescription.Get(), new Actions.DisplayMessageBoxParams() { diff --git a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs index 57c53890..7084748f 100644 --- a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs +++ b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs @@ -174,17 +174,21 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List< { info = (FILE_DIRECTORY_INFORMATION*)currentBufferPtr; - // Not symlink or symlink to offline file. - if ((info->FileAttributes & FileAttributes.ReparsePoint) != 0 && - (info->FileAttributes & FileAttributes.Offline) == 0) - goto nextfile; - var fileName = Marshal.PtrToStringUni(currentBufferPtr + sizeof(FILE_DIRECTORY_INFORMATION), (int)info->FileNameLength / 2); if (fileName == "." || fileName == "..") goto nextfile; var isDirectory = (info->FileAttributes & FileAttributes.Directory) > 0; + + // Skip symlinks/junctions that point elsewhere (prevents infinite recursion). + // Cloud folders (e.g. OneDrive) use different reparse tags, so they are + // treated as normal directories and enumerated. Only checked for directories; + // files with reparse points are always enumerated. + if (isDirectory && (info->FileAttributes & FileAttributes.ReparsePoint) != 0 && + IsNameSurrogateReparsePoint($@"{originalDirPath}\{fileName}")) + goto nextfile; + if (isDirectory) { directories.Add(new DirectoryInformation @@ -193,7 +197,7 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List< LastWriteTime = info->LastWriteTime.ToDateTime() }); } - else if (!isDirectory) + else { files.Add(new FileInformation { @@ -218,6 +222,63 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List< return true; } + /// + /// Returns true if the reparse point at is a 'name surrogate' + /// (a symlink or junction/mount-point that redirects the path). Such entries are skipped + /// during enumeration to prevent following them into cycles. + /// + /// Cloud providers (e.g. OneDrive) also use reparse points on real, locally-available + /// folders; those tags do not carry the name-surrogate bit, so this returns false for them + /// and the directory is enumerated normally. + /// + /// On any failure to determine the tag we conservatively return false (do not skip), since + /// wrongly skipping a real directory is the original bug this guards against. A genuine + /// symlink whose tag cannot be read would still be bounded by maxDepth. + /// + private static unsafe bool IsNameSurrogateReparsePoint(string fullPath) + { + const uint FSCTL_GET_REPARSE_POINT = 0x000900A8; + + // Bit set in the reparse tag of entries that substitute/redirect the name + // (symlinks, junctions/mount-points). Cloud reparse tags do not set this bit. + const uint REPARSE_TAG_NAME_SURROGATE = 0x20000000; + + const uint GENERIC_READ = 0x80000000; + const uint FILE_SHARE_READ = 0x00000001; + const uint FILE_SHARE_WRITE = 0x00000002; + const uint OPEN_EXISTING = 3; + + // BACKUP_SEMANTICS allows opening directories; OPEN_REPARSE_POINT opens the + // reparse entry itself instead of resolving through it. + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + + const int BufferSize = 1024 * 16; + + var handle = CreateFileW(fullPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + + if (handle == new IntPtr(-1)) + return false; + + try + { + byte* buffer = stackalloc byte[BufferSize]; + if (!DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, IntPtr.Zero, 0, + (IntPtr)buffer, BufferSize, out _, IntPtr.Zero)) + { + return false; + } + + // REPARSE_DATA_BUFFER.ReparseTag is the first DWORD. + return (*(uint*)buffer & REPARSE_TAG_NAME_SURROGATE) != 0; + } + finally + { + CloseHandle(handle); + } + } + internal struct MultithreadedDirectorySearcher : IDisposable { private Thread[] _threads; @@ -315,6 +376,17 @@ public void Dispose() [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern IntPtr CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CloseHandle(IntPtr hObject); #endregion #region Native Import Wrappers