Skip to content

Commit 00d5dfe

Browse files
rmarinhoCopilot
andauthored
Fix PathUtils.IsSymlink throwing on common lstat failures (#183)
* Fix PathUtils.IsSymlink throwing on common lstat failures 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> * 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> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ac7f924 commit 00d5dfe

3 files changed

Lines changed: 123 additions & 2 deletions

File tree

Xamarin.MacDev/PathUtils.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ static int lstat (string path, out Stat buf)
4848
}
4949
}
5050

51+
const int ENOENT = 2;
52+
const int EACCES = 13;
53+
const int ENOTDIR = 20;
54+
55+
/// <summary>
56+
/// Returns whether the given path is a symlink.
57+
/// Returns false (rather than throwing) when the path cannot be examined
58+
/// due to non-existence, permissions, or a non-directory path component.
59+
/// </summary>
5160
public static bool IsSymlink (string file)
5261
{
5362
if (Environment.OSVersion.Platform == PlatformID.Win32NT) {
@@ -56,8 +65,12 @@ public static bool IsSymlink (string file)
5665
}
5766
Stat buf;
5867
var rv = lstat (file, out buf);
59-
if (rv != 0)
60-
throw new Exception (string.Format ("Could not lstat '{0}': {1}", file, Marshal.GetLastWin32Error ()));
68+
if (rv != 0) {
69+
var errno = Marshal.GetLastWin32Error ();
70+
if (errno == ENOENT || errno == EACCES || errno == ENOTDIR)
71+
return false;
72+
throw new Exception (string.Format ("Could not lstat '{0}': {1}", file, errno));
73+
}
6174
const int S_IFLNK = 40960;
6275
return (buf.st_mode & S_IFLNK) == S_IFLNK;
6376
}

Xamarin.MacDev/Xamarin.MacDev.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
<ItemGroup Condition=" '$(TargetFramework)' != 'netstandard2.0' ">
3939
<Compile Remove="NullableAttributes.cs" />
4040
</ItemGroup>
41+
<ItemGroup>
42+
<InternalsVisibleTo Include="tests" />
43+
</ItemGroup>
4144
<ItemGroup>
4245
<PackageReference Include="System.Text.Json" Version="8.0.5" Condition=" '$(TargetFramework)' == 'netstandard2.0' " />
4346
</ItemGroup>

tests/PathUtilsTests.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
#nullable enable
5+
6+
using System.IO;
7+
using NUnit.Framework;
8+
using Xamarin.MacDev;
9+
10+
namespace tests {
11+
12+
[TestFixture]
13+
public class PathUtilsTests {
14+
15+
[Test]
16+
[Platform ("MacOsX")]
17+
public void IsSymlink_ReturnsFalse_ForNonExistentFile ()
18+
{
19+
var path = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ());
20+
// Should not throw; returns false for ENOENT
21+
Assert.That (PathUtils.IsSymlink (path), Is.False);
22+
}
23+
24+
[Test]
25+
[Platform ("MacOsX")]
26+
public void IsSymlink_ReturnsFalse_ForRegularFile ()
27+
{
28+
var path = Path.GetTempFileName ();
29+
try {
30+
Assert.That (PathUtils.IsSymlink (path), Is.False);
31+
} finally {
32+
File.Delete (path);
33+
}
34+
}
35+
36+
[Test]
37+
[Platform ("MacOsX")]
38+
public void IsSymlink_ReturnsTrue_ForSymlink ()
39+
{
40+
var target = Path.GetTempFileName ();
41+
var link = target + ".link";
42+
try {
43+
#if NET
44+
File.CreateSymbolicLink (link, target);
45+
#else
46+
// File.CreateSymbolicLink is not available on net472.
47+
// Use a shell command to create the symlink on macOS.
48+
var psi = new System.Diagnostics.ProcessStartInfo ("ln", $"-s \"{target}\" \"{link}\"") {
49+
UseShellExecute = false,
50+
};
51+
System.Diagnostics.Process.Start (psi)!.WaitForExit ();
52+
#endif
53+
Assert.That (PathUtils.IsSymlink (link), Is.True);
54+
} finally {
55+
File.Delete (link);
56+
File.Delete (target);
57+
}
58+
}
59+
60+
[Test]
61+
[Platform ("MacOsX")]
62+
public void IsSymlinkOrHasParentSymlink_ReturnsFalse_ForNonExistentPath ()
63+
{
64+
var path = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ());
65+
Assert.That (PathUtils.IsSymlinkOrHasParentSymlink (path), Is.False);
66+
}
67+
68+
[Test]
69+
[Platform ("MacOsX")]
70+
public void IsSymlink_ReturnsFalse_WhenPathComponentIsNotDirectory ()
71+
{
72+
// /etc/hosts is a file, so /etc/hosts/bogus triggers ENOTDIR
73+
var path = Path.Combine ("/etc/hosts", "bogus");
74+
Assert.That (PathUtils.IsSymlink (path), Is.False);
75+
}
76+
77+
[Test]
78+
[Platform ("MacOsX")]
79+
public void IsSymlinkOrHasParentSymlink_ReturnsTrue_WhenParentIsSymlink ()
80+
{
81+
var realDir = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ());
82+
Directory.CreateDirectory (realDir);
83+
var childDir = Path.Combine (realDir, "subdir");
84+
Directory.CreateDirectory (childDir);
85+
86+
var linkDir = Path.Combine (Path.GetTempPath (), Path.GetRandomFileName ());
87+
try {
88+
#if NET
89+
Directory.CreateSymbolicLink (linkDir, realDir);
90+
#else
91+
var psi = new System.Diagnostics.ProcessStartInfo ("ln", $"-s \"{realDir}\" \"{linkDir}\"") {
92+
UseShellExecute = false,
93+
};
94+
System.Diagnostics.Process.Start (psi)!.WaitForExit ();
95+
#endif
96+
var childViaLink = Path.Combine (linkDir, "subdir");
97+
Assert.That (PathUtils.IsSymlinkOrHasParentSymlink (childViaLink), Is.True);
98+
} finally {
99+
if (Directory.Exists (linkDir))
100+
Directory.Delete (linkDir);
101+
Directory.Delete (realDir, recursive: true);
102+
}
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)