Skip to content

Commit 2014272

Browse files
committed
Changed: Cap directory scan recursion instead of skipping reparse points
The scanner now follows all reparse points (junctions, symlinks, cloud placeholders) as normal directories. Infinite recursion via junction cycles is bounded by a hard depth cap (256) in GetFilesEx, matching how .NET's EnumerationOptions handles it. This replaces the name-surrogate reparse-point check, which ran CreateFileW + DeviceIoControl per reparse-pointed directory and caused OneDrive/cloud files to be skipped (#882).
1 parent 718548a commit 2014272

3 files changed

Lines changed: 32 additions & 92 deletions

File tree

changelog-template.hbs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,34 @@
33

44
# 1.30.2: Cloud Storage & Wine Improvements
55

6-
## Generalize Cloud Folder Detection Beyond OneDrive
6+
## Fix Broken File Detection in OneDrive Folders
77

8-
The fast hand-rolled directory scanner previously accidentally skipped some
9-
contents of OneDrive cloud folders.
8+
The directory scanner (file search) skipped symlinks for 'offline' (non-local) files.
109

11-
Previously in Reloaded-II, we skipped resolving Symlinks in the `Mods` folder;
12-
and symlinks to 'offline' files (not on current machine).
10+
> The file is offline. The data of the file is not immediately available.
11+
> [FileAttributes.Offline](https://learn.microsoft.com/en-us/dotnet/api/system.io.fileattributes?view=net-10.0#fields)
1312

14-
Turns out- files under OneDrive can classify as symlinks- so in some cases- they
15-
were just not being picked up. Unfortunately- optimizations here have backfired.
13+
A younger me, half a decade ago misunderstood this to mean as
14+
'this file cannot be accessed' or 'on a server, but you're offline'.
1615

17-
Coincidentally, [Opst34](https://github.com/Reloaded-Project/Reloaded-II/issues/882) ran into
18-
an issue where they couldn't configure their mod- due to the OneDrive sync feature; which
19-
is how I found out.
16+
This was not correct- what it rather means is, if you try to read the data; it will
17+
fetch it from the cloud rather than local storage. This means that if a file is
18+
on cloud, but not on local storage; we would not recognise it.
2019

21-
This may have been the culprit of the infamous endless update loop some folks experienced
22-
when inside OneDrive.
20+
[Opst34](https://github.com/Reloaded-Project/Reloaded-II/issues/882) reported a mod that
21+
could not be configured due to the OneDrive sync feature.
2322

24-
In any case, we now separate symlinks and cloud storage folders apart, to best
25-
of ability.
23+
Over the years, this may also have caused the infamous endless update loop some
24+
users saw inside OneDrive.
2625

27-
In reality, we probably should just allow all files- but this was the most painless
28-
fix (for now).
26+
The scanner no longer ignores these files. Just in case, infinite recursion is also handled,
27+
by a hard recursion-depth cap (256). So if you got cyclic symlinks like
28+
`C:\Reloaded-II\Mods\Loop\Loop\Loop\...`, it will stop after 256 iterations rather
29+
than hanging.
30+
31+
Detection of cloud storage also updated; since users who have installed Windows
32+
without OneDrive enabled, and then opted into it later would not be caught. This
33+
however wasn't well tested; but should not cause regression in worse case.
2934

3035
## Only Write `steamappid.txt` File When Correct ID Is Obtained by @datasone0
3136

source/Reloaded.Mod.Loader.IO/Utility/IOEx.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ public static class IOEx
44
{
55
private static readonly char[] InvalidFilePathChars = Path.GetInvalidPathChars().Union(Path.GetInvalidFileNameChars()).ToArray();
66

7+
/// <summary>
8+
/// Hard ceiling on recursion depth to prevent infinite loops via reparse point
9+
/// (junction/symlink) cycles, since the runtime follows reparse points without
10+
/// cycle detection. 256 is well above any legitimate mod directory depth.
11+
/// </summary>
12+
private const int MaxRecursionDepthCap = 256;
13+
714
/// <summary>
815
/// Moves a directory from a given source path to a target path, overwriting all files.
916
/// </summary>
@@ -175,6 +182,7 @@ public static bool CheckFileAccess(string filePath, FileMode mode = FileMode.Ope
175182
/// <param name="recurseOnFound">Continues to search in subdirectories even if <see cref="fileName"/> is found.</param>
176183
public static List<string> GetFilesEx(string directory, string fileName, int maxDepth = 1, int minDepth = 1, bool recurseOnFound = true)
177184
{
185+
maxDepth = Math.Min(maxDepth, MaxRecursionDepthCap);
178186
var files = new List<string>();
179187

180188
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))

source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs

Lines changed: 3 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,9 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List<
181181

182182
var isDirectory = (info->FileAttributes & FileAttributes.Directory) > 0;
183183

184-
// Skip symlinks/junctions that point elsewhere (prevents infinite recursion).
185-
// Cloud folders (e.g. OneDrive) use different reparse tags, so they are
186-
// treated as normal directories and enumerated. Only checked for directories;
187-
// files with reparse points are always enumerated.
188-
if (isDirectory && (info->FileAttributes & FileAttributes.ReparsePoint) != 0 &&
189-
IsNameSurrogateReparsePoint($@"{originalDirPath}\{fileName}"))
190-
goto nextfile;
191-
184+
// Reparse points (junctions/symlinks/cloud placeholders) are followed as
185+
// normal directories. Infinite recursion via junction cycles is bounded by
186+
// a recursion-depth cap in the caller (IOEx.GetFilesEx).
192187
if (isDirectory)
193188
{
194189
directories.Add(new DirectoryInformation
@@ -222,63 +217,6 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List<
222217
return true;
223218
}
224219

225-
/// <summary>
226-
/// Returns true if the reparse point at <paramref name="fullPath"/> is a 'name surrogate'
227-
/// (a symlink or junction/mount-point that redirects the path). Such entries are skipped
228-
/// during enumeration to prevent following them into cycles.
229-
///
230-
/// Cloud providers (e.g. OneDrive) also use reparse points on real, locally-available
231-
/// folders; those tags do not carry the name-surrogate bit, so this returns false for them
232-
/// and the directory is enumerated normally.
233-
///
234-
/// On any failure to determine the tag we conservatively return false (do not skip), since
235-
/// wrongly skipping a real directory is the original bug this guards against. A genuine
236-
/// symlink whose tag cannot be read would still be bounded by <c>maxDepth</c>.
237-
/// </summary>
238-
private static unsafe bool IsNameSurrogateReparsePoint(string fullPath)
239-
{
240-
const uint FSCTL_GET_REPARSE_POINT = 0x000900A8;
241-
242-
// Bit set in the reparse tag of entries that substitute/redirect the name
243-
// (symlinks, junctions/mount-points). Cloud reparse tags do not set this bit.
244-
const uint REPARSE_TAG_NAME_SURROGATE = 0x20000000;
245-
246-
const uint GENERIC_READ = 0x80000000;
247-
const uint FILE_SHARE_READ = 0x00000001;
248-
const uint FILE_SHARE_WRITE = 0x00000002;
249-
const uint OPEN_EXISTING = 3;
250-
251-
// BACKUP_SEMANTICS allows opening directories; OPEN_REPARSE_POINT opens the
252-
// reparse entry itself instead of resolving through it.
253-
const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
254-
const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
255-
256-
const int BufferSize = 1024 * 16;
257-
258-
var handle = CreateFileW(fullPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
259-
IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
260-
261-
if (handle == new IntPtr(-1))
262-
return false;
263-
264-
try
265-
{
266-
byte* buffer = stackalloc byte[BufferSize];
267-
if (!DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, IntPtr.Zero, 0,
268-
(IntPtr)buffer, BufferSize, out _, IntPtr.Zero))
269-
{
270-
return false;
271-
}
272-
273-
// REPARSE_DATA_BUFFER.ReparseTag is the first DWORD.
274-
return (*(uint*)buffer & REPARSE_TAG_NAME_SURROGATE) != 0;
275-
}
276-
finally
277-
{
278-
CloseHandle(handle);
279-
}
280-
}
281-
282220
internal struct MultithreadedDirectorySearcher : IDisposable
283221
{
284222
private Thread[] _threads;
@@ -376,17 +314,6 @@ public void Dispose()
376314

377315
[DllImport("kernel32.dll", CharSet = CharSet.Ansi)]
378316
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
379-
380-
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
381-
internal static extern IntPtr CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
382-
383-
[DllImport("kernel32.dll", SetLastError = true)]
384-
[return: MarshalAs(UnmanagedType.Bool)]
385-
internal static extern bool DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped);
386-
387-
[DllImport("kernel32.dll", SetLastError = true)]
388-
[return: MarshalAs(UnmanagedType.Bool)]
389-
internal static extern bool CloseHandle(IntPtr hObject);
390317
#endregion
391318

392319
#region Native Import Wrappers

0 commit comments

Comments
 (0)