Skip to content

Commit 555c352

Browse files
committed
Merge branch 'improve-symbolic-link-creation' into 'main'
Replace symbolic link P/Invoke with managed APIs and improve test coverage See merge request Sharpmake/sharpmake!631
2 parents ef205eb + 4d5d8d3 commit 555c352

File tree

2 files changed

+278
-40
lines changed

2 files changed

+278
-40
lines changed

Sharpmake.UnitTests/UtilTest.cs

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,21 +1293,112 @@ public class SymbolicLink
12931293
/// <remark>Implementation of create symbolic links doesn't work on linux so this test is discard on Mono</remark>
12941294
/// </summary>
12951295
[Test]
1296-
[Ignore("Test Broken")]
12971296
public void CreateSymbolicLinkOnDirectory()
12981297
{
1299-
if (!Util.IsRunningInMono())
1298+
var tempBase = Path.Combine(Path.GetTempPath(), $"test-symlink-dir-{Guid.NewGuid():N}");
1299+
var tempSource = Path.Combine(tempBase, $"test-source");
1300+
var tempSourceRelative = Path.Combine(tempBase, $"..\\{new DirectoryInfo(tempBase).Name}\\test-source");
1301+
var tempSourceNonEmpty = Path.Combine(tempBase, $"test-source-nonempty");
1302+
var tempSourceNonEmptyFile = Path.Combine(tempSourceNonEmpty, $"test1.txt");
1303+
var tempDestination1 = Path.Combine(tempBase, $"test-destination1");
1304+
var tempDestination1File = Path.Combine(tempDestination1, $"test2.txt");
1305+
var tempDestination2 = Path.Combine(tempBase, $"test-destination2");
1306+
1307+
try
13001308
{
1301-
var tempDirectory1 = Directory.CreateDirectory(Path.GetTempPath() + Path.DirectorySeparatorChar + "test-source");
1302-
var tempDirectory2 = Directory.CreateDirectory(Path.GetTempPath() + Path.DirectorySeparatorChar + "test-destination");
1309+
Directory.CreateDirectory(tempDestination1);
1310+
File.WriteAllText(tempDestination1File, "Some content");
1311+
Directory.CreateDirectory(tempDestination2);
1312+
1313+
// Invalid case: source and destination are the same
1314+
Assert.Throws<IOException>(() => Util.CreateOrUpdateSymbolicLink(Path.GetTempPath(), Path.GetTempPath(), true));
1315+
1316+
// Validate creation of a new symbolic link
1317+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource, tempDestination1, true), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.Created));
1318+
Assert.True(new DirectoryInfo(tempSource).Attributes.HasFlag(FileAttributes.ReparsePoint));
1319+
Assert.That(Directory.ResolveLinkTarget(tempSource, false).FullName, Is.EqualTo(tempDestination1));
1320+
1321+
// Validate updating a symbolic
1322+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource, tempDestination2, true), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.Updated));
1323+
Assert.True(new DirectoryInfo(tempSource).Attributes.HasFlag(FileAttributes.ReparsePoint));
1324+
Assert.That(Directory.ResolveLinkTarget(tempSource, false).FullName, Is.EqualTo(tempDestination2));
1325+
Assert.True(File.Exists(tempDestination1File)); // Verify that the content of the old symlink is still there
1326+
1327+
// Validate noop when the symbolic link is already up to date
1328+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource, tempDestination2, true), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate));
1329+
1330+
// Validate with alt directory separator char
1331+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), tempDestination2, true), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate));
1332+
1333+
// Validate with relative path
1334+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSourceRelative, tempDestination2, true), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate));
1335+
1336+
// Validate that creating a symbolic link on a non empty directory succeeds (deleting its content)
1337+
Directory.CreateDirectory(tempSourceNonEmpty);
1338+
File.WriteAllText(tempSourceNonEmptyFile, "Some content");
1339+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSourceNonEmpty, tempDestination1, true), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.Updated));
1340+
Assert.False(File.Exists(tempSourceNonEmptyFile)); // Should have been deleted when the symlink was created
1341+
}
1342+
finally
1343+
{
1344+
try { Directory.Delete(tempBase, true); } catch { }
1345+
}
1346+
}
13031347

1304-
Assert.False(Util.CreateSymbolicLink(Path.GetTempPath(), Path.GetTempPath(), true));
1305-
Assert.False(tempDirectory1.Attributes.HasFlag(FileAttributes.ReparsePoint));
1306-
Assert.True(Util.CreateSymbolicLink(tempDirectory1.FullName, tempDirectory2.FullName, true));
1307-
Assert.True(tempDirectory1.Attributes.HasFlag(FileAttributes.ReparsePoint));
1348+
/// <summary>
1349+
/// <c>CreateSymbolicLinkOnFile</c> create a symbolic link for files
1350+
/// The test cases are:
1351+
/// <list type="number">
1352+
/// <item><description>Testing that a symbolic link is not created on a file pointing itself</description></item>
1353+
/// <item><description>Testing that a symbolic link is created on a file pointing to another file</description></item>
1354+
/// <item><description>Testing updating a file symbolic link to point to a different target</description></item>
1355+
/// <item><description>Testing that no operation occurs when file symbolic link is already up to date</description></item>
1356+
/// </list>
1357+
/// <remark>Implementation of create symbolic links doesn't work on linux so this test is discard on Mono</remark>
1358+
/// </summary>
1359+
[Test]
1360+
public void CreateSymbolicLinkOnFile()
1361+
{
1362+
var tempBase = Path.Combine(Path.GetTempPath(), $"test-symlink-file-{Guid.NewGuid():N}");
1363+
var tempSource = Path.Combine(tempBase, $"test-file-source.txt");
1364+
var tempSourceRelative = Path.Combine(tempBase, $"..\\{new DirectoryInfo(tempBase).Name}\\test-file-source.txt");
1365+
var tempDestination1 = Path.Combine(tempBase, $"test-file-destination1.txt");
1366+
var tempDestination2 = Path.Combine(tempBase, $"test-file-destination2.txt");
13081367

1309-
Directory.Delete(tempDirectory1.FullName);
1310-
Directory.Delete(tempDirectory2.FullName);
1368+
try
1369+
{
1370+
// Create destination files with some content
1371+
Directory.CreateDirectory(tempBase);
1372+
File.WriteAllText(tempDestination1, "Destination 1 content");
1373+
File.WriteAllText(tempDestination2, "Destination 2 content");
1374+
1375+
// Invalid case: source and destination are the same
1376+
Assert.Throws<IOException>(() => Util.CreateOrUpdateSymbolicLink(tempDestination1, tempDestination1, false));
1377+
1378+
// Validate creation of a new symbolic link
1379+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource, tempDestination1, false), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.Created));
1380+
Assert.True(new FileInfo(tempSource).Attributes.HasFlag(FileAttributes.ReparsePoint));
1381+
Assert.That(File.ResolveLinkTarget(tempSource, false).FullName, Is.EqualTo(tempDestination1));
1382+
Assert.True(Util.IsSymbolicLink(tempSource));
1383+
1384+
// Validate updating a symbolic link
1385+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource, tempDestination2, false), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.Updated));
1386+
Assert.True(new FileInfo(tempSource).Attributes.HasFlag(FileAttributes.ReparsePoint));
1387+
Assert.That(File.ResolveLinkTarget(tempSource, false).FullName, Is.EqualTo(tempDestination2));
1388+
Assert.True(Util.IsSymbolicLink(tempSource));
1389+
1390+
// Validate noop when the symbolic link is already up to date
1391+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource, tempDestination2, false), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate));
1392+
1393+
// Validate with alt directory separator char
1394+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSource.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), tempDestination2, false), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate));
1395+
1396+
// Validate with relative path
1397+
Assert.That(Util.CreateOrUpdateSymbolicLink(tempSourceRelative, tempDestination2, false), Is.EqualTo(Util.CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate));
1398+
}
1399+
finally
1400+
{
1401+
try { Directory.Delete(tempBase, true); } catch { }
13111402
}
13121403
}
13131404

@@ -1321,22 +1412,18 @@ public void CreateSymbolicLinkOnDirectory()
13211412
/// <remark>Implementation of create symbolic links doesn't work on linux so this test is discard on Mono</remark>
13221413
/// </summary>
13231414
[Test]
1324-
[Ignore("Test Broken")]
13251415
public void IsSymbolicLink()
13261416
{
1327-
if (!Util.IsRunningInMono())
1328-
{
1329-
var mockPath1 = Path.GetTempFileName();
1330-
var mockPath2 = Path.GetTempFileName();
1417+
var mockPath1 = Path.GetTempFileName();
1418+
var mockPath2 = Path.GetTempFileName();
13311419

1332-
Assert.False(Util.IsSymbolicLink(mockPath1));
1420+
Assert.False(Util.IsSymbolicLink(mockPath1));
13331421

1334-
Assert.True(Util.CreateSymbolicLink(mockPath1, mockPath2, false));
1335-
Assert.True(Util.IsSymbolicLink(mockPath1));
1422+
Assert.DoesNotThrow(() => Util.CreateOrUpdateSymbolicLink(mockPath1, mockPath2, false));
1423+
Assert.True(Util.IsSymbolicLink(mockPath1));
13361424

1337-
File.Delete(mockPath1);
1338-
File.Delete(mockPath2);
1339-
}
1425+
File.Delete(mockPath1);
1426+
File.Delete(mockPath2);
13401427
}
13411428
}
13421429

Sharpmake/Util.cs

Lines changed: 170 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,39 +1117,190 @@ private void SerializeSequence<T>(IEnumerable sequence, Tuple<char, char> delimi
11171117
}
11181118
}
11191119

1120+
[Obsolete("Use CreateOrUpdateSymbolicLink instead", error: true)]
11201121
public static bool CreateSymbolicLink(string source, string target, bool isDirectory)
11211122
{
1122-
bool success = false;
1123+
return false;
1124+
}
1125+
1126+
private static Lazy<bool> _UseElevatedShellForSymlinks = new (() => UseElevatedShellForSymlinks());
1127+
private static bool UseElevatedShellForSymlinks()
1128+
{
1129+
if (!OperatingSystem.IsWindows())
1130+
return false;
1131+
1132+
// Detect if we can create symlinks without elevation. We know a couple of cases where we can:
1133+
// 1) Running as administrator
1134+
// 2) Developer mode is enabled on Windows 10 and later
1135+
// 3) User is granted the SeCreateSymbolicLinkPrivilege privilege (typically via a group policy)
1136+
// We can test this by trying to create a temporary symlink and see if it works which.
1137+
// It is a lot simpler than trying to detect all those cases individually.
1138+
bool requiresElevation = true;
1139+
string tempSource = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
1140+
string tempTarget = null;
11231141
try
11241142
{
1125-
// In case the file is marked as readonly
1143+
tempTarget = Path.GetTempFileName();
1144+
File.CreateSymbolicLink(tempSource, tempTarget);
1145+
requiresElevation = false;
1146+
}
1147+
catch
1148+
{
1149+
}
1150+
finally
1151+
{
1152+
try { File.Delete(tempSource); } catch { }
1153+
try { File.Delete(tempTarget); } catch { }
1154+
}
1155+
1156+
return requiresElevation;
1157+
}
1158+
1159+
public enum CreateOrUpdateSymbolicLinkResult
1160+
{
1161+
Created,
1162+
Updated,
1163+
AlreadyUpToDate
1164+
}
1165+
1166+
/// <summary>
1167+
/// Creates or updates a symbolic link from source to target.
1168+
/// </summary>
1169+
/// <remarks>
1170+
/// On Windows when not running as administrator, this method will attempt to use an elevated shell
1171+
/// to create the symbolic link (requires UAC elevation prompt). On other platforms or when running
1172+
/// as administrator, the managed APIs are used directly.
1173+
/// </remarks>
1174+
/// <param name="source">The path where the symbolic link will be created. Does not need to exist initially.</param>
1175+
/// <param name="target">The path the symbolic link will point to. Must exist.</param>
1176+
/// <param name="isDirectory">If true, creates a directory symbolic link; if false, creates a file symbolic link.</param>
1177+
/// <returns>
1178+
/// <see cref="CreateOrUpdateSymbolicLinkResult.Created"/> if a new symbolic link was created;
1179+
/// <see cref="CreateOrUpdateSymbolicLinkResult.Updated"/> if an existing symbolic link was updated to point to a different target;
1180+
/// <see cref="CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate"/> if the symbolic link already points to the target.
1181+
/// </returns>
1182+
/// <exception cref="ArgumentException">Thrown if source or target paths are null or empty.</exception>
1183+
/// <exception cref="IOException">Thrown if source and target are the same path, or if link creation/update fails.</exception>
1184+
/// <exception cref="Exception">Thrown if the elevated shell fails to start (Windows only).</exception>
1185+
public static CreateOrUpdateSymbolicLinkResult CreateOrUpdateSymbolicLink(string source, string target, bool isDirectory)
1186+
{
1187+
if (string.IsNullOrWhiteSpace(source))
1188+
throw new ArgumentException("Source path cannot be null or empty", nameof(source));
1189+
if (string.IsNullOrWhiteSpace(target))
1190+
throw new ArgumentException("Target path cannot be null or empty", nameof(target));
1191+
1192+
// Note: Can't have a slash at end of path and replacing alternate slashes so that resolved link target comparison works correctly
1193+
target = Path.GetFullPath(target).TrimEnd(Path.DirectorySeparatorChar);
1194+
source = Path.GetFullPath(source).TrimEnd(Path.DirectorySeparatorChar);
1195+
if (source == target)
1196+
{
1197+
throw new IOException("Source and target paths are the same for symbolic link: " + source);
1198+
}
1199+
1200+
CreateOrUpdateSymbolicLinkResult result;
1201+
if (isDirectory)
1202+
{
1203+
if (Directory.Exists(source))
1204+
{
1205+
var linkTarget = Directory.ResolveLinkTarget(source, false);
1206+
if (linkTarget == null || linkTarget.FullName != target)
1207+
{
1208+
result = CreateOrUpdateSymbolicLinkResult.Updated;
1209+
if (linkTarget == null)
1210+
{
1211+
Directory.Delete(source, true); // Not a symlink, delete recursively
1212+
}
1213+
else
1214+
{
1215+
Directory.Delete(source);
1216+
}
1217+
}
1218+
else
1219+
{
1220+
result = CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate;
1221+
}
1222+
}
1223+
else
1224+
{
1225+
result = CreateOrUpdateSymbolicLinkResult.Created;
1226+
}
1227+
}
1228+
else
1229+
{
11261230
if (File.Exists(source))
11271231
{
1128-
File.SetAttributes(source, FileAttributes.Normal);
1129-
File.Delete(source);
1232+
var linkTarget = File.ResolveLinkTarget(source, false);
1233+
if (linkTarget == null || linkTarget.FullName != target)
1234+
{
1235+
result = CreateOrUpdateSymbolicLinkResult.Updated;
1236+
File.SetAttributes(source, FileAttributes.Normal);
1237+
File.Delete(source);
1238+
}
1239+
else
1240+
{
1241+
result = CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate;
1242+
}
11301243
}
1131-
else if (Directory.Exists(source))
1244+
else
11321245
{
1133-
Directory.Delete(source);
1246+
result = CreateOrUpdateSymbolicLinkResult.Created;
11341247
}
1248+
}
11351249

1136-
int releaseId = int.Parse(GetRegistryLocalMachineSubKeyValue(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "ReleaseId", "0"));
1250+
switch (result)
1251+
{
1252+
case CreateOrUpdateSymbolicLinkResult.Updated:
1253+
LogWrite($"Updating symbolic link: {source} => {target}");
1254+
break;
11371255

1138-
int flags = isDirectory ? SYMBOLIC_LINK_FLAG_DIRECTORY : SYMBOLIC_LINK_FLAG_FILE;
1139-
if (releaseId >= 1703) // Verify that the Windows build is equal or above 1703, as SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE was introduced at that version. Using it on older version will cause an error 87 and symlinks won't be created
1140-
flags |= SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE;
1256+
case CreateOrUpdateSymbolicLinkResult.AlreadyUpToDate:
1257+
LogWrite($"Symbolic link already up to date: {source} => {target}");
1258+
return result; // nothing to do bail out
11411259

1142-
success = CreateSymbolicLink(source, target, flags);
1260+
case CreateOrUpdateSymbolicLinkResult.Created:
1261+
LogWrite($"Creating symbolic link: {source} => {target}");
1262+
break;
11431263
}
1144-
catch { }
1145-
return success;
1146-
}
11471264

1148-
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
1149-
private static extern bool CreateSymbolicLink(string lpSymlinkFileName, string lpTargetFileName, int dwFlags);
1150-
private const int SYMBOLIC_LINK_FLAG_FILE = 0x0;
1151-
private const int SYMBOLIC_LINK_FLAG_DIRECTORY = 0x1;
1152-
private const int SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x2;
1265+
// Create intermediate directories
1266+
Directory.CreateDirectory(Path.GetDirectoryName(source));
1267+
1268+
if (_UseElevatedShellForSymlinks.Value)
1269+
{
1270+
// Used to create symlink without requiring the whole process to be elevated
1271+
string command = $"mklink {(isDirectory ? "/D" : string.Empty)} \"{source}\" \"{target}\"";
1272+
string arguments = $"/C \"cd /D \"{Environment.CurrentDirectory}\" && {command}\"";
1273+
var processStartInfo = new ProcessStartInfo("cmd", arguments)
1274+
{
1275+
UseShellExecute = true,
1276+
Verb = "runas",
1277+
WindowStyle = ProcessWindowStyle.Hidden
1278+
};
1279+
var process = Process.Start(processStartInfo);
1280+
if (process != null)
1281+
{
1282+
process.WaitForExit();
1283+
if (process.ExitCode != 0)
1284+
{
1285+
throw new IOException($"Failed creating or updating symbolic link with elevate shell, exited with code {process.ExitCode} for command: {command}");
1286+
}
1287+
}
1288+
else
1289+
{
1290+
throw new Exception($"Failed starting elevate shell to create or update symbolic link, command: {command}.");
1291+
}
1292+
}
1293+
else if (isDirectory)
1294+
{
1295+
Directory.CreateSymbolicLink(source, target);
1296+
}
1297+
else
1298+
{
1299+
File.CreateSymbolicLink(source, target);
1300+
}
1301+
1302+
return result;
1303+
}
11531304

11541305
public static bool IsSymbolicLink(string path)
11551306
{

0 commit comments

Comments
 (0)