diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs index f959b4a55097cf..9a275b38731635 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs @@ -697,26 +697,15 @@ internal static void CreateHardLink(string path, string pathToTarget) } else { - string? current = linkTarget; - int visitCount = 1; - - while (current != null) - { - if (visitCount > MaxFollowedLinks) - { - sb.Dispose(); - // We went over the limit and couldn't reach the final target - throw new IOException(SR.Format(SR.IO_TooManySymbolicLinkLevels, linkPath)); - } - - GetLinkTargetFullPath(ref sb, current); - current = Interop.Sys.ReadLink(sb.AsSpan()); - visitCount++; - } + sb.Dispose(); + linkTarget = ResolveFinalTarget(linkPath, linkTarget); } - Debug.Assert(sb.Length > 0); - linkTarget = sb.ToString(); // ToString disposes + if (!returnFinalTarget) + { + Debug.Assert(sb.Length > 0); + linkTarget = sb.ToString(); // ToString disposes + } return isDirectory ? new DirectoryInfo(linkTarget) : @@ -738,6 +727,82 @@ static void GetLinkTargetFullPath(ref ValueStringBuilder sb, ReadOnlySpan } sb.Append(linkTarget); } + + static string ResolveFinalTarget(string linkPath, string linkTarget) + { + int visitCount = 1; + string currentPath = Path.GetFullPath(linkPath); + string[] pendingComponents = new string[8]; + int pendingComponentCount = 0; + + // Resolve relative targets one component at a time because an intermediate component may itself be a link. + FollowLinkTarget(linkTarget); + + while (pendingComponentCount > 0) + { + string component = pendingComponents[--pendingComponentCount]; + pendingComponents[pendingComponentCount] = null!; + + if (component == ".") + { + continue; + } + + if (component == "..") + { + currentPath = Path.GetDirectoryName(currentPath) ?? currentPath; + continue; + } + + currentPath = Path.Join(currentPath, component); + string? nestedTarget = Interop.Sys.ReadLink(currentPath); + if (nestedTarget != null) + { + ThrowIfTooManyLinks(); + FollowLinkTarget(nestedTarget); + } + } + + return currentPath; + + void FollowLinkTarget(string target) + { + // Preserve the lexical path of absolute targets while following a link at that exact path. + while (!PathInternal.IsPartiallyQualified(target)) + { + currentPath = target; + string? nextTarget = Interop.Sys.ReadLink(currentPath); + if (nextTarget == null) + { + return; + } + + ThrowIfTooManyLinks(); + target = nextTarget; + } + + currentPath = Path.GetDirectoryName(currentPath)!; + string[] components = target.Split(PathInternal.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + if (pendingComponentCount + components.Length > pendingComponents.Length) + { + Array.Resize(ref pendingComponents, Math.Max(pendingComponentCount + components.Length, pendingComponents.Length * 2)); + } + + for (int i = components.Length - 1; i >= 0; i--) + { + pendingComponents[pendingComponentCount++] = components[i]; + } + } + + void ThrowIfTooManyLinks() + { + visitCount++; + if (visitCount > MaxFollowedLinks) + { + throw new IOException(SR.Format(SR.IO_TooManySymbolicLinkLevels, linkPath)); + } + } + } } } } diff --git a/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Base/SymbolicLinks/BaseSymbolicLinks.FileSystem.cs b/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Base/SymbolicLinks/BaseSymbolicLinks.FileSystem.cs index 1c4004194beb92..cf83c8ff933c77 100644 --- a/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Base/SymbolicLinks/BaseSymbolicLinks.FileSystem.cs +++ b/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Base/SymbolicLinks/BaseSymbolicLinks.FileSystem.cs @@ -262,6 +262,51 @@ public void ResolveLinkTarget_ReturnFinalTarget_Relative_WithRedundantSegments() filePath: filePath); } + [Theory] + [InlineData(false)] + [InlineData(true)] + [PlatformSpecific(TestPlatforms.AnyUnix)] + public void ResolveLinkTarget_ReturnFinalTarget_RelativeIntermediateDirectoryLink(bool targetExists) + { + string rootPath = GetRandomDirPath(); + Directory.CreateDirectory(rootPath); + + const string versionDirectoryName = "..2025_08_14_07_17_19.1395403829"; + string versionDirectoryPath = Path.Join(rootPath, versionDirectoryName); + Directory.CreateDirectory(versionDirectoryPath); + + string targetName = IsDirectoryTest ? "target-directory" : "target-file"; + string targetPath = Path.Join(versionDirectoryPath, targetName); + if (targetExists) + { + CreateFileOrDirectory(targetPath); + } + + Directory.CreateSymbolicLink(Path.Join(rootPath, "..data"), versionDirectoryName); + + string linkPath = Path.Join(rootPath, "link"); + CreateSymbolicLink(linkPath, Path.Join("..data", targetName)); + + FileSystemInfo targetInfo = ResolveLinkTarget(linkPath, returnFinalTarget: true); + Assert.Equal(targetPath, targetInfo.FullName); + Assert.Equal(targetExists, targetInfo.Exists); + } + + [Fact] + [PlatformSpecific(TestPlatforms.AnyUnix)] + public void ResolveLinkTarget_ReturnFinalTarget_IntermediateDirectoryLinkCycle_Throws() + { + string rootPath = GetRandomDirPath(); + Directory.CreateDirectory(rootPath); + + Directory.CreateSymbolicLink(Path.Join(rootPath, "cycle"), "cycle"); + + string linkPath = Path.Join(rootPath, "link"); + CreateSymbolicLink(linkPath, Path.Join("cycle", "target")); + + Assert.Throws(() => ResolveLinkTarget(linkPath, returnFinalTarget: true)); + } + [Theory] [InlineData(1, false)] [InlineData(10, false)]