Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add internal junction support to link APIs #57996

Merged
merged 19 commits into from
Aug 27, 2021

Conversation

carlossanlop
Copy link
Member

@carlossanlop carlossanlop commented Aug 24, 2021

In Windows, if a user creates a virtual drive using the subst command, they are actually creating a junction that points to a drive.

By adding junction support to our internal GetImmediateLinkTarget method, if the user creates symbolic links that point to files or directories inside that virtual drive, we can now ensure that the public API ResolveLinkTarget(returnFinalTarget: false) returns a FileSystemInfo in which the FullName points to a path that uses said virtual drive.

Note: When the user calls ResolveLinkTarget(returnFinalTarget: true), we use a different P/Invoke, which returns the absolute normalized path of the symlink's final target. This is expected behavior from Windows. So this code path remains untouched.

The root cause was the same as above: Both LinkTarget and ResolveLinkTarget(returnFinalTarget: false) call the internal method GetImmediateLinkTarget, which returns null if a reparse point that is not a symlink is encountered. And because ResolveLinkTarget(returnFinalTarget: true) calls a different P/Invoke, it is able to resolve any kind of reparse point, which explains the discrepancy. By adding support for mount points in GetImmediateLinkTarget, we can now return the target properly.

cc @fitdev

@ghost
Copy link

ghost commented Aug 24, 2021

Tagging subscribers to this area: @dotnet/area-system-io
See info in area-owners.md if you want to be subscribed.

Issue Details

In Windows, if a user creates a virtual drive using the subst command, they are actually creating a junction that points to a drive.

By adding junction support to our internal GetImmediateLinkTarget method, if the user creates symbolic links that point to files or directories inside that virtual drive, we can now ensure that the public API ResolveLinkTarget(returnFinalTarget: false) returns a FileSystemInfo in which the FullName points to a path that uses said virtual drive.

Note: When the user calls ResolveLinkTarget(returnFinalTarget: true), we use a different P/Invoke, which returns the absolute normalized path of the symlink's final target. This is expected behavior from Windows. So this code path remains untouched.

We decided that when a FileSystemInfo wraps a junction, calling LinkTarget should return null for 6.0. This is so the user can determine that the file is not a symlink.

Author: carlossanlop
Assignees: carlossanlop, Jozkee
Labels:

area-System.IO

Milestone: 6.0.0

@carlossanlop carlossanlop marked this pull request as ready for review August 24, 2021 21:42
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct REPARSE_DATA_BUFFER
internal unsafe struct SymbolicLinkReparseBuffer
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed this to match the names that were provided in the DUMMYUNIONNAME section of the official Windows docs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +8 to +9
// Need to reuse the same virtual drive for all the test methods.
// Creating and disposing one virtual drive per class achieves this.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If instead of doing this, I splitted the virtual drive tests into multiple different test classes, then each test class would create its own virtual drive. We have so many symlink tests now, that we would end up depleting the available drive letters.

Comment on lines -505 to -509
// Only symbolic links are supported at the moment.
if ((rdb.ReparseTag & Interop.Kernel32.IOReparseOptions.IO_REPARSE_TAG_SYMLINK) == 0)
{
return null;
}
Copy link
Member Author

@carlossanlop carlossanlop Aug 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ReparseTag is not a flag, so the bitwise operation was unnecessary. Examples from PowerShell: 1, 2.
  • The if logic is now reversed, so that we only return null if the link is not a symlink or a mount point.

Copy link
Member

@jozkee jozkee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Source changes look mostly good to me; the tests are a bit hard to comprehend and I think we are missing some scenarios.

@carlossanlop
Copy link
Member Author

Seems Windows Nano does not have subst available. The CI is failing in that leg.

I'll have to add the IsNotWindowsNanoServer condition attribute to the virtual drive tests.

Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM, but please address the nits that I've found before we merge it. Thank you @carlossanlop !


/// <summary>
/// Changes the current working directory path to a new temporary directory.
/// Important: Make sure to call this inside a remote executor to avoid changing the cwd for all tests in same process.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to ensure that it's called from Remote Executor?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we can add such a check here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would have to be something like this
Debug.Assert(Environment.StackTrace.Contains("RemoteExecutor"));
or it would be reasonable to add a static flag in RemoteExecutor so you could just test RemoteExecutor.IsInRemoteExecutor.

return (0 == junctionProcess.ExitCode);
}

public static char CreateVirtualDrive(string targetDir)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am totally OK with a test creating a temporary drive on my machine.

@ViktorHofer could it be an issue for the CI?

Copy link
Member

@ViktorHofer ViktorHofer Aug 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dotnet/dnceng @MattGal is creating a temporary virtual drive as part of a test which runs on PRs and rolling builds something that we should avoid?

Copy link
Member

@MattGal MattGal Aug 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am totally OK with a test creating a temporary drive on my machine.

We should maybe make sure this is a general consensus with real users who might run your test on their personal machines; the odds of leaking are there.

As for whether it's something to avoid, that depends. TL;DR: Probably OK, but you might need to consider cleanup automation (there's not even a try around this code, if it blows up it just leaves things where it was...) I'll give a few responses since I don't know exactly where this will run.

  • If running on an Azure Devops Hosted build machine - no issues, these are unique VMs per build
  • If running on a helix-based buildpool.* machine: my (admittedly limited) understanding of subst is that the virtual drive mappings don't persist across reboots; we always reboot, no issues as long as this is accurate.
  • If running on a VM-based Helix test machine: it's probably OK because you're the only user creating random drive mappings, but if there's a failure to cleanup and the same machine runs automation doing this repeatedly, as I don't think you can get more than 26 drive letters.
  • If running on an on-premises, physical Helix test machine (every Windows ARM machine) : scariest possible choice; anything that's actually persistent might exist a long time, so if there's a letter-leak this is where I'd expect trouble.

All that said, I think if you had some sort of try {} finally {} here that cleans up the mapping when you're done, you're Ok.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MattGal thank you for a very detailed answer!

Copy link
Member Author

@carlossanlop carlossanlop Aug 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you had some sort of try {} finally {} here that cleans up the mapping when you're done, you're Ok.

I added a Dispose method to the test class, which clears the mapped drive. And by the way, the test class only creates one mapped drive and reuses it for all of its test methods. precisely because I was seeing the problem of using all the available drive letters. Now I only use one per test class.

I'm not sure what I would add to the finally part of the try catch I added to the Dispose method. Suggestions? Or is it good as it is?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW I have no preference, but note for other "somewhat impactful" tests we made them outerloop. That way we rarely impact our contributors, but still are properly covered. Example: the process.start tests that pop up the browser. It's an option but I have no opinion.

Copy link
Member

@jozkee jozkee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise; LGTM. Only suggestions are for tests.

char driveLetter = GetRandomDriveLetter();

Process substProcess = new Process();
substProcess.StartInfo.FileName = "subst";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the subst call ever gets called twice, it would fail with the message Drive already SUBSTed.

Is there something that catches that error? my concern is that a race condition could occur and subst gets unintentionally executed twice and the CI would fail. Or maybe is not a big deal and we can wait for the CI to actually fail.

// Need to reuse the same virtual drive for all the test methods.
// Creating and disposing one virtual drive per class achieves this.
[PlatformSpecific(TestPlatforms.Windows)]
[ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know why this is not supported in Nano?

Copy link
Member Author

@carlossanlop carlossanlop Aug 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows Nano Server is a stripped down version of Windows. A bunch of components are removed to make it lightweight. The CI error says the subst executable was not found. It was supposed to be located in C:\Windows\System32, which is part of either PATH or some default env variable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if subst is not in the PATH for Windows Nano, can't we use the full path to the subst executable?

// [InlineData(true, true, false, true, false)] // Target is not in virtual drive
[InlineData(true, true, true, false, true)] // Immediate target expected, target is in virtual drive
[InlineData(true, true, true, true, false)] // Final target expected, target is in virtual drive
public void VirtualDrive_SymbolicLinks_WithIndirection(bool isFirstLinkInVirtualDrive, bool isMiddleLinkInVirtualDrive, bool isTargetInVirtualDrive, bool returnFinalTarget, bool isExpectedTargetPathVirtual)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this method you can add another parameter to the previous test, name it isIndirect or something alike.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I attempted it but the test became way more complicated than it already is. I would rather keep them separate.

@@ -48,20 +46,78 @@ public static bool CreateSymbolicLink(string linkPath, string targetPath, bool i
symLinkProcess.StartInfo.RedirectStandardOutput = true;
symLinkProcess.Start();

if (symLinkProcess != null)
symLinkProcess.WaitForExit();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the code above was not modified, but we should rather refactor it as well:

Depeneding on your personal preferences it could be:

public static bool CreateSymbolicLink(string linkPath, string targetPath, bool isDirectory)
{
    ProcessStartInfo startInfo = OperatingSystem.IsWindows()
        ? CreateStartInfo("cmd", "/c", "mklink", isDirectory ? "/D" : "", linkPath, targetPath)
        : CreateStartInfo("/bin/ln",  "-s", targetPath, linkPath);

    return RunProcess(startInfo);
}

Or

public static bool CreateSymbolicLink(string linkPath, string targetPath, bool isDirectory)
    => RunProcess(OperatingSystem.IsWindows()
        ? CreateStartInfo("cmd", "/c", "mklink", isDirectory ? "/D" : "", linkPath, targetPath)
        : CreateStartInfo("/bin/ln",  "-s", targetPath, linkPath));

Copy link
Member Author

@carlossanlop carlossanlop Aug 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also refactored it, initially. I used the code you shared the first time. It caused all the symlink enumeration tests to fail. It was taking me too long to figure out why so I decided to revert it and the tests passed again.
I can take a look again in the related PR for AppExecLinks, if you don't mind.

@carlossanlop carlossanlop merged commit f7848bc into dotnet:main Aug 27, 2021
@carlossanlop carlossanlop deleted the reparsepoint branch August 27, 2021 18:36
@carlossanlop
Copy link
Member Author

/backport to release/6.0-rc1

@github-actions
Copy link
Contributor

Started backporting to release/6.0-rc1: https://github.com/dotnet/runtime/actions/runs/1175308538

@ghost ghost locked as resolved and limited conversation to collaborators Sep 27, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
6 participants