From ae7c268d26742973ae247861bcf5ef91e4709d2b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 29 May 2026 16:11:42 +0100 Subject: [PATCH 1/2] Fix PathUtils.IsSymlink throwing on common lstat failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsSymlink previously threw an exception for any lstat failure, including ENOENT (file not found) and EACCES (permission denied). These are expected non-exceptional conditions — especially when walking directory trees to check for symlinks via IsSymlinkOrHasParentSymlink. Now returns false for ENOENT, EACCES, and ENOTDIR, and only throws for genuinely unexpected errno values. Uses named constants for clarity. Also adds InternalsVisibleTo for the test project and new PathUtilsTests (5 tests covering non-existent, regular, symlink, parent-walk, and ENOTDIR). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/PathUtils.cs | 17 +++++- Xamarin.MacDev/Xamarin.MacDev.csproj | 3 ++ tests/PathUtilsTests.cs | 78 ++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 tests/PathUtilsTests.cs diff --git a/Xamarin.MacDev/PathUtils.cs b/Xamarin.MacDev/PathUtils.cs index 34bc51d..c366509 100644 --- a/Xamarin.MacDev/PathUtils.cs +++ b/Xamarin.MacDev/PathUtils.cs @@ -48,6 +48,15 @@ static int lstat (string path, out Stat buf) } } + const int ENOENT = 2; + const int EACCES = 13; + const int ENOTDIR = 20; + + /// + /// Returns whether the given path is a symlink. + /// Returns false (rather than throwing) when the path cannot be examined + /// due to non-existence, permissions, or a non-directory path component. + /// public static bool IsSymlink (string file) { if (Environment.OSVersion.Platform == PlatformID.Win32NT) { @@ -56,8 +65,12 @@ public static bool IsSymlink (string file) } Stat buf; var rv = lstat (file, out buf); - if (rv != 0) - throw new Exception (string.Format ("Could not lstat '{0}': {1}", file, Marshal.GetLastWin32Error ())); + if (rv != 0) { + var errno = Marshal.GetLastWin32Error (); + if (errno == ENOENT || errno == EACCES || errno == ENOTDIR) + return false; + throw new Exception (string.Format ("Could not lstat '{0}': {1}", file, errno)); + } const int S_IFLNK = 40960; return (buf.st_mode & S_IFLNK) == S_IFLNK; } diff --git a/Xamarin.MacDev/Xamarin.MacDev.csproj b/Xamarin.MacDev/Xamarin.MacDev.csproj index 30f6fb0..fa16155 100644 --- a/Xamarin.MacDev/Xamarin.MacDev.csproj +++ b/Xamarin.MacDev/Xamarin.MacDev.csproj @@ -38,6 +38,9 @@ + + + diff --git a/tests/PathUtilsTests.cs b/tests/PathUtilsTests.cs new file mode 100644 index 0000000..23e155d --- /dev/null +++ b/tests/PathUtilsTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.IO; +using NUnit.Framework; +using Xamarin.MacDev; + +namespace tests { + + [TestFixture] + public class PathUtilsTests { + + [Test] + [Platform ("MacOsX")] + public void IsSymlink_ReturnsFalse_ForNonExistentFile () + { + var path = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ()); + // Should not throw; returns false for ENOENT + Assert.That (PathUtils.IsSymlink (path), Is.False); + } + + [Test] + [Platform ("MacOsX")] + public void IsSymlink_ReturnsFalse_ForRegularFile () + { + var path = Path.GetTempFileName (); + try { + Assert.That (PathUtils.IsSymlink (path), Is.False); + } finally { + File.Delete (path); + } + } + + [Test] + [Platform ("MacOsX")] + public void IsSymlink_ReturnsTrue_ForSymlink () + { + var target = Path.GetTempFileName (); + var link = target + ".link"; + try { +#if NET + File.CreateSymbolicLink (link, target); +#else + // File.CreateSymbolicLink is not available on net472. + // Use a shell command to create the symlink on macOS. + var psi = new System.Diagnostics.ProcessStartInfo ("ln", $"-s \"{target}\" \"{link}\"") { + UseShellExecute = false, + }; + System.Diagnostics.Process.Start (psi)!.WaitForExit (); +#endif + Assert.That (PathUtils.IsSymlink (link), Is.True); + } finally { + File.Delete (link); + File.Delete (target); + } + } + + [Test] + [Platform ("MacOsX")] + public void IsSymlinkOrHasParentSymlink_ReturnsFalse_ForNonExistentPath () + { + var path = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ()); + Assert.That (PathUtils.IsSymlinkOrHasParentSymlink (path), Is.False); + } + + [Test] + [Platform ("MacOsX")] + public void IsSymlink_ReturnsFalse_WhenPathComponentIsNotDirectory () + { + // /etc/hosts is a file, so /etc/hosts/bogus triggers ENOTDIR + var path = Path.Combine ("/etc/hosts", "bogus"); + Assert.That (PathUtils.IsSymlink (path), Is.False); + } + } +} From 32debebdd39fd71ffc456cf13c03f5a2f125d81f Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 29 May 2026 18:39:44 +0100 Subject: [PATCH 2/2] Address review feedback: remove unused using, add parent-symlink test - Remove unused 'using System;' from PathUtilsTests.cs - Add IsSymlinkOrHasParentSymlink_ReturnsTrue_WhenParentIsSymlink test that creates a symlink directory and verifies parent traversal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/PathUtilsTests.cs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/PathUtilsTests.cs b/tests/PathUtilsTests.cs index 23e155d..9c2f81b 100644 --- a/tests/PathUtilsTests.cs +++ b/tests/PathUtilsTests.cs @@ -3,7 +3,6 @@ #nullable enable -using System; using System.IO; using NUnit.Framework; using Xamarin.MacDev; @@ -74,5 +73,33 @@ public void IsSymlink_ReturnsFalse_WhenPathComponentIsNotDirectory () var path = Path.Combine ("/etc/hosts", "bogus"); Assert.That (PathUtils.IsSymlink (path), Is.False); } + + [Test] + [Platform ("MacOsX")] + public void IsSymlinkOrHasParentSymlink_ReturnsTrue_WhenParentIsSymlink () + { + var realDir = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ()); + Directory.CreateDirectory (realDir); + var childDir = Path.Combine (realDir, "subdir"); + Directory.CreateDirectory (childDir); + + var linkDir = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ()); + try { +#if NET + Directory.CreateSymbolicLink (linkDir, realDir); +#else + var psi = new System.Diagnostics.ProcessStartInfo ("ln", $"-s \"{realDir}\" \"{linkDir}\"") { + UseShellExecute = false, + }; + System.Diagnostics.Process.Start (psi)!.WaitForExit (); +#endif + var childViaLink = Path.Combine (linkDir, "subdir"); + Assert.That (PathUtils.IsSymlinkOrHasParentSymlink (childViaLink), Is.True); + } finally { + if (Directory.Exists (linkDir)) + Directory.Delete (linkDir); + Directory.Delete (realDir, recursive: true); + } + } } }