Skip to content

Commit

Permalink
Create proper relative symbolic links
Browse files Browse the repository at this point in the history
  • Loading branch information
manfred-brands committed Jan 4, 2023
1 parent ac0911a commit 3c4f76c
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 20 deletions.
87 changes: 87 additions & 0 deletions src/Framework.UnitTests/GetRelativePath_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.IO;
using Xunit;

namespace Microsoft.Build.Framework.UnitTests
{
// Test cases taken from .NET 7.0 runtime.
public static class GetRealtivePath_Tests
{
[Theory]
[InlineData(@"C:\", @"C:\", @".")]
[InlineData(@"C:\a", @"C:\a\", @".")]
[InlineData(@"C:\a\", @"C:\a", @".")]
[InlineData(@"C:\", @"C:\b", @"b")]
[InlineData(@"C:\a", @"C:\b", @"..\b")]
[InlineData(@"C:\a", @"C:\b\", @"..\b\")]
[InlineData(@"C:\a\b", @"C:\a", @"..")]
[InlineData(@"C:\a\b", @"C:\a\", @"..")]
[InlineData(@"C:\a\b\", @"C:\a", @"..")]
[InlineData(@"C:\a\b\", @"C:\a\", @"..")]
[InlineData(@"C:\a\b\c", @"C:\a\b", @"..")]
[InlineData(@"C:\a\b\c", @"C:\a\b\", @"..")]
[InlineData(@"C:\a\b\c", @"C:\a", @"..\..")]
[InlineData(@"C:\a\b\c", @"C:\a\", @"..\..")]
[InlineData(@"C:\a\b\c\", @"C:\a\b", @"..")]
[InlineData(@"C:\a\b\c\", @"C:\a\b\", @"..")]
[InlineData(@"C:\a\b\c\", @"C:\a", @"..\..")]
[InlineData(@"C:\a\b\c\", @"C:\a\", @"..\..")]
[InlineData(@"C:\a\", @"C:\b", @"..\b")]
[InlineData(@"C:\a", @"C:\a\b", @"b")]
[InlineData(@"C:\a", @"C:\b\c", @"..\b\c")]
[InlineData(@"C:\a\", @"C:\a\b", @"b")]
[InlineData(@"C:\", @"D:\", @"D:\")]
[InlineData(@"C:\", @"D:\b", @"D:\b")]
[InlineData(@"C:\", @"D:\b\", @"D:\b\")]
[InlineData(@"C:\a", @"D:\b", @"D:\b")]
[InlineData(@"C:\a\", @"D:\b", @"D:\b")]
[InlineData(@"C:\ab", @"C:\a", @"..\a")]
[InlineData(@"C:\a", @"C:\ab", @"..\ab")]
[InlineData(@"C:\", @"\\LOCALHOST\Share\b", @"\\LOCALHOST\Share\b")]
[InlineData(@"\\LOCALHOST\Share\a", @"\\LOCALHOST\Share\b", @"..\b")]
[PlatformSpecific(TestPlatforms.Windows)] // Tests Windows-specific paths
public static void GetRelativePath_Windows(string relativeTo, string path, string expected)
{
string result = NativeMethods.GetRelativePath(relativeTo, path);
Assert.Equal(expected, result);

// Check that we get the equivalent path when the result is combined with the sources
Assert.Equal(
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar),
Path.GetFullPath(Path.Combine(Path.GetFullPath(relativeTo), result)).TrimEnd(Path.DirectorySeparatorChar),
ignoreCase: true,
ignoreLineEndingDifferences: false,
ignoreWhiteSpaceDifferences: false);
}

[Theory]
[InlineData(@"/", @"/", @".")]
[InlineData(@"/a", @"/a/", @".")]
[InlineData(@"/a/", @"/a", @".")]
[InlineData(@"/", @"/b", @"b")]
[InlineData(@"/a", @"/b", @"../b")]
[InlineData(@"/a/", @"/b", @"../b")]
[InlineData(@"/a", @"/a/b", @"b")]
[InlineData(@"/a", @"/b/c", @"../b/c")]
[InlineData(@"/a/", @"/a/b", @"b")]
[InlineData(@"/ab", @"/a", @"../a")]
[InlineData(@"/a", @"/ab", @"../ab")]
[PlatformSpecific(TestPlatforms.AnyUnix & ~TestPlatforms.Browser)] // Tests Unix-specific paths
public static void GetRelativePath_AnyUnix(string relativeTo, string path, string expected)
{
string result = NativeMethods.GetRelativePath(relativeTo, path);

// Somehow the PlatformSpecific seems to be ignored inside Visual Studio
result = result.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

Assert.Equal(expected, result);

// Check that we get the equivalent path when the result is combined with the sources
Assert.Equal(
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar),
Path.GetFullPath(Path.Combine(Path.GetFullPath(relativeTo), result)).TrimEnd(Path.DirectorySeparatorChar));
}
}
}
119 changes: 114 additions & 5 deletions src/Framework/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1136,9 +1136,13 @@ internal static bool TryGetFinalLinkTarget(FileInfo fileInfo, out string finalTa
return true;
}

internal static bool MakeSymbolicLink(string newFileName, string exitingFileName, ref string errorMessage)
internal static bool MakeSymbolicLink(string newFileName, string existingFileName, ref string errorMessage)
{
bool symbolicLinkCreated;

string targetDirectory = Path.GetDirectoryName(newFileName) ?? Directory.GetCurrentDirectory();
existingFileName = Path.IsPathRooted(existingFileName) ? existingFileName : GetRelativePath(targetDirectory, existingFileName);

if (IsWindows)
{
Version osVersion = Environment.OSVersion.Version;
Expand All @@ -1148,26 +1152,131 @@ internal static bool MakeSymbolicLink(string newFileName, string exitingFileName
flags |= SymbolicLink.AllowUnprivilegedCreate;
}

symbolicLinkCreated = CreateSymbolicLink(newFileName, exitingFileName, flags);
symbolicLinkCreated = CreateSymbolicLink(newFileName, existingFileName, flags);
errorMessage = symbolicLinkCreated ? null : Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()).Message;
}
else
{
symbolicLinkCreated = symlink(exitingFileName, newFileName) == 0;
symbolicLinkCreated = symlink(existingFileName, newFileName) == 0;
errorMessage = symbolicLinkCreated ? null : "The link() library call failed with the following error code: " + Marshal.GetLastWin32Error();
}

return symbolicLinkCreated;
}

internal static string GetRelativePath(string relativeTo, string path)
{
#if NETSTANDARD2_1 || NETCOREAPP2_0_OR_GREATER || NET5_0_OR_GREATER
return Path.GetRelativePath(relativeTo, path);
#else
// Based upon .NET 7.0 runtime Path.GetRelativePath
relativeTo = Path.GetFullPath(relativeTo);
path = Path.GetFullPath(path);
int commonLength = GetCommonPathLength(relativeTo, path);
if (commonLength == 0)
{
return path; // No common part, use absolute path
}

int relativeToLength = relativeTo.Length;
if (EndsInDirectorySeparator(relativeTo))
{
relativeToLength--;
}

bool pathEndsInSeparator = EndsInDirectorySeparator(path);
int pathLength = path.Length;
if (pathEndsInSeparator)
{
pathLength--;
}

if (relativeToLength == pathLength && commonLength >= relativeToLength)
{
return ".";
}

var sb = new StringBuilder(Math.Max(relativeTo.Length, path.Length));

if (commonLength < relativeToLength)
{
sb.Append("..");

for (int i = commonLength + 1; i < relativeToLength; i++)
{
if (IsDirectorySeparator(relativeTo[i]))
{
sb.Append(Path.DirectorySeparatorChar);
sb.Append("..");
}
}
}
else if (IsDirectorySeparator(path[commonLength]))
{
// No parent segments and we need to eat the initial separator.
// (C:\Foo C:\Foo\Bar case)
commonLength++;
}

// Now add the rest of the "to" path, adding back the trailing separator
int differenceLength = pathLength - commonLength;
if (pathEndsInSeparator)
differenceLength++;

if (differenceLength > 0)
{
if (sb.Length > 0)
{
sb.Append(Path.DirectorySeparatorChar);
}

sb.Append(path.Substring(commonLength));
}

return sb.ToString();

static bool IsDirectorySeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
static bool EndsInDirectorySeparator(string path) => path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]);
static int GetCommonPathLength(string first, string second)
{
int n = Math.Min(first.Length, second.Length);
int commonLength;
for (commonLength = 0; commonLength < n; commonLength++)
{
// Case sensitive compare, even some NTFS directories can be case sensitive
if (first[commonLength] != second[commonLength])
break;
}

// If nothing matches
if (commonLength == 0)
return commonLength;

// Or we're a full string and equal length or match to a separator
if (commonLength == first.Length
&& (commonLength == second.Length || IsDirectorySeparator(second[commonLength])))
return commonLength;

if (commonLength == second.Length && IsDirectorySeparator(first[commonLength]))
return commonLength;

// It's possible we matched somewhere in the middle of a segment e.g. C:\Foodie and C:\Foobar.
while (commonLength > 0 && !IsDirectorySeparator(first[commonLength - 1]))
commonLength--;

return commonLength;
}
#endif
}

/// <summary>
/// Get the last write time of the fullpath to the file.
/// </summary>
/// <param name="fullPath">Full path to the file in the filesystem</param>
/// <returns>The last write time of the file, or DateTime.MinValue if the file does not exist.</returns>
/// <remarks>
/// This method should be accurate for regular files and symlinks, but can report incorrect data
/// if the file's content was modified by writing to it through a different link, unless
/// if the file's content was modified by writing to it through a different link, unlessLL
/// MSBUILDALWAYSCHECKCONTENTTIMESTAMP=1.
/// </remarks>
internal static DateTime GetLastWriteFileUtcTime(string fullPath)
Expand Down Expand Up @@ -1655,7 +1764,7 @@ internal static void VerifyThrowWin32Result(int result)
[DllImport("kernel32.dll")]
[SupportedOSPlatform("windows")]
internal static extern uint GetFileType(IntPtr hFile);

[DllImport("kernel32.dll")]
internal static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);

Expand Down
62 changes: 47 additions & 15 deletions src/Tasks.UnitTests/Copy_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2395,19 +2395,40 @@ public CopySymbolicLink_Tests(ITestOutputHelper testOutputHelper)
/// <summary>
/// DestinationFolder should work.
/// </summary>
[Fact]
public void CopyToDestinationFolderWithSymbolicLinkCheck()
/// <param name="useRelative">Use a relative path, e.g. obj/Debug</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void CopyToDestinationFolderWithSymbolicLinkCheck(bool useRelative)
{
string sourceFile = FileUtilities.GetTemporaryFile();
string temp = Path.GetTempPath();
string destFolder = Path.Combine(temp, "2A333ED756AF4dc392E728D0F864A398");
string destFile = Path.Combine(destFolder, Path.GetFileName(sourceFile));
string currentDirectory = Directory.GetCurrentDirectory();
string sourceFolder = null;
string absoluteSourceFile = null;
string destFolder = null;
string destFile = null;

try
{
File.WriteAllText(sourceFile, "This is a source temp file."); // HIGHCHAR: Test writes in UTF8 without preamble.
string temp = Path.GetTempPath();

// Due to symlinks from /var to /private/var on MacOs
// Setting the current directory to /var/... returns a current directory of /private/var/...
// This causes the relative path from /private/var to /var to contain a lot of ../ components up to the root.
// Although that should work it is not what we are testing here.
Directory.SetCurrentDirectory(temp);
temp = Directory.GetCurrentDirectory();

sourceFolder = Path.Combine(temp, $"MSBuildSymLinkTemp{Environment.UserName}");
Directory.CreateDirectory(sourceFolder);
absoluteSourceFile = FileUtilities.GetTemporaryFile(sourceFolder, null, ".tmp", false);
destFolder = Path.Combine(temp, "2A333ED756AF4dc392E728D0F864A398");
destFile = Path.Combine(destFolder, Path.GetFileName(absoluteSourceFile));
string relativeSourceFile = useRelative ? absoluteSourceFile.Substring(temp.Length + 1) : absoluteSourceFile;

File.WriteAllText(absoluteSourceFile, "This is a source temp file."); // HIGHCHAR: Test writes in UTF8 without preamble.

// Don't create the dest folder, let task do that
ITaskItem[] sourceFiles = { new TaskItem(sourceFile) };
ITaskItem[] sourceFiles = { new TaskItem(relativeSourceFile) };

var me = new MockEngine(true);
var t = new Copy
Expand All @@ -2426,9 +2447,16 @@ public void CopyToDestinationFolderWithSymbolicLinkCheck()
Assert.True(File.Exists(destFile)); // "destination exists"
Assert.True((File.GetAttributes(destFile) & FileAttributes.ReparsePoint) != 0, "File was copied but is not a symlink");

#if NET6_0_OR_GREATER
string expectedRelativeLink = Path.Combine("..", relativeSourceFile);
var info = new FileInfo(destFile);

Assert.Equal(expectedRelativeLink, info.LinkTarget);
#endif

MockEngine.GetStringDelegate resourceDelegate = AssemblyResources.GetString;

me.AssertLogContainsMessageFromResource(resourceDelegate, "Copy.SymbolicLinkComment", sourceFile, destFile);
me.AssertLogContainsMessageFromResource(resourceDelegate, "Copy.SymbolicLinkComment", relativeSourceFile, destFile);

string destinationFileContents = File.ReadAllText(destFile);
Assert.Equal("This is a source temp file.", destinationFileContents); // "Expected the destination symbolic linked file to contain the contents of source file."
Expand All @@ -2440,9 +2468,8 @@ public void CopyToDestinationFolderWithSymbolicLinkCheck()

// Now we will write new content to the source file
// we'll then check that the destination file automatically
// has the same content (i.e. it's been hard linked)

File.WriteAllText(sourceFile, "This is another source temp file."); // HIGHCHAR: Test writes in UTF8 without preamble.
// has the same content (i.e. it's been linked)
File.WriteAllText(absoluteSourceFile, "This is another source temp file."); // HIGHCHAR: Test writes in UTF8 without preamble.

// Read the destination file (it should have the same modified content as the source)
destinationFileContents = File.ReadAllText(destFile);
Expand All @@ -2452,9 +2479,14 @@ public void CopyToDestinationFolderWithSymbolicLinkCheck()
}
finally
{
File.Delete(sourceFile);
File.Delete(destFile);
FileUtilities.DeleteWithoutTrailingBackslash(destFolder, true);
Directory.SetCurrentDirectory(currentDirectory);
if (destFile != null)
{
File.Delete(absoluteSourceFile);
File.Delete(destFile);
FileUtilities.DeleteWithoutTrailingBackslash(destFolder, true);
FileUtilities.DeleteWithoutTrailingBackslash(sourceFolder, true);
}
}
}

Expand Down

0 comments on commit 3c4f76c

Please sign in to comment.