8000 Enhance Remove-Item to work with OneDrive (Third) by iSazonov · Pull Request #15571 · PowerShell/PowerShell · GitHub
[go: up one dir, main page]

Skip to content

Enhance Remove-Item to work with OneDrive (Third) #15571

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

Merged
merged 11 commits into from
Aug 5, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -2699,7 +2699,7 @@ protected override void ProcessRecord()
try
{
System.IO.DirectoryInfo di = new(providerPath);
if (di != null && (di.Attributes & System.IO.FileAttributes.ReparsePoint) != 0)
if (InternalSymbolicLinkLinkCodeMethods.IsReparsePointLikeSymlink(di))
{
shouldRecurse = false;
treatAsFile = true;
Expand Down
10 changes: 9 additions & 1 deletion src/System.Management.Automation/engine/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1864,7 +1864,7 @@ internal static string GetFormatStyleString(FormatStyle formatStyle)

if (ExperimentalFeature.IsEnabled("PSAnsiRendering"))
{
PSStyle psstyle = PSStyle.Instance;
PSStyle psstyle = PSStyle.Instance;
switch (formatStyle)
{
case FormatStyle.Reset:
Expand Down Expand Up @@ -2104,6 +2104,14 @@ public static class InternalTestHooks

internal static bool ThrowExdevErrorOnMoveDirectory;

// To emulate OneDrive behavior we use the hard-coded symlink.
// If OneDriveTestRecurseOn is false then the symlink works as regular symlink.
// If OneDriveTestRecurseOn is true then we recurse into the symlink as OneDrive should work.
// OneDriveTestSymlinkName defines the symlink name used in tests.
internal static bool OneDriveTestOn;
internal static bool OneDriveTestRecurseOn;
internal static string OneDriveTestSymlinkName = "link-Beta";

/// <summary>This member is used for internal test purposes.</summary>
public static void SetTestHook(string property, object value)
{
Expand Down
84 changes: 62 additions & 22 deletions src/System.Management.Automation/namespaces/FileSystemProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1891,9 +1891,14 @@ private void Dir(
}

bool hidden = false;
bool checkReparsePoint = true;
if (!Force)
{
hidden = (recursiveDirectory.Attributes & FileAttributes.Hidden) != 0;

// We've already taken the expense of initializing the Attributes property here,
// so we can use that to avoid needing to call IsReparsePointLikeSymlink() later.
checkReparsePoint = recursiveDirectory.Attributes.HasFlag(FileAttributes.ReparsePoint);
}

// if "Hidden" is explicitly specified anywhere in the attribute filter, then override
Expand All @@ -1907,7 +1912,7 @@ private void Dir(
// c) it is not a reparse point with a target (not OneDrive or an AppX link).
if (tracker == null)
{
if (InternalSymbolicLinkLinkCodeMethods.IsReparsePointWithTarget(recursiveDirectory))
if (checkReparsePoint && InternalSymbolicLinkLinkCodeMethods.IsReparsePointLikeSymlink(recursiveDirectory))
{
continue;
}
Expand Down Expand Up @@ -2062,7 +2067,7 @@ public static string NameString(PSObject instance)
{
if (instance?.BaseObject is FileSystemInfo fileInfo)
{
if (InternalSymbolicLinkLinkCodeMethods.IsReparsePointWithTarget(fileInfo))
if (InternalSymbolicLinkLinkCodeMethods.IsReparsePointLikeSymlink(fileInfo))
{
return $"{PSStyle.Instance.FileInfo.SymbolicLink}{fileInfo.Name}{PSStyle.Instance.Reset} -> {InternalSymbolicLinkLinkCodeMethods.GetTarget(instance)}";
}
Expand Down Expand Up @@ -2090,7 +2095,7 @@ public static string NameString(PSObject instance)
else
{
return instance?.BaseObject is FileSystemInfo fileInfo
? InternalSymbolicLinkLinkCodeMethods.IsReparsePointWithTarget(fileInfo)
? InternalSymbolicLinkLinkCodeMethods.IsReparsePointLikeSymlink(fileInfo)
? $"{fileInfo.Name} -> {InternalSymbolicLinkLinkCodeMethods.GetTarget(instance)}"
: fileInfo.Name
: string.Empty;
Expand Down Expand Up @@ -3131,22 +3136,31 @@ private void RemoveDirectoryInfoItem(DirectoryInfo directory, bool recurse, bool
continueRemoval = ShouldProcess(directory.FullName, action);
}

if (directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
if (InternalSymbolicLinkLinkCodeMethods.IsReparsePointLikeSymlink(directory))
{
void WriteErrorHelper(Exception exception)
{
WriteError(new ErrorRecord(exception, errorId: "DeleteSymbolicLinkFailed", ErrorCategory.WriteError, directory));
}

try
{
// TODO:
// Different symlinks seem to vary by behavior.
// In particular, OneDrive symlinks won't remove without recurse,
// but the .NET API here does not allow us to distinguish them.
// We may need to revisit using p/Invokes here to get the right behavior
directory.Delete();
if (InternalTestHooks.OneDriveTestOn)
{
WriteErrorHelper(new IOException());
return;
}
else
{
// Name surrogates should just be detached.
directory.Delete();
}
}
catch (Exception e)
{
string error = StringUtil.Format(FileSystemProviderStrings.CannotRemoveItem, directory.FullName, e.Message);
var exception = new IOException(error, e);
WriteError(new ErrorRecord(exception, errorId: "DeleteSymbolicLinkFailed", ErrorCategory.WriteError, directory));
WriteErrorHelper(exception);
}

return;
Expand Down Expand Up @@ -8056,8 +8070,7 @@ protected override bool ReleaseHandle()
private static extern bool FindClose(IntPtr handle);
}

// SetLastError is false as the use of this API doesn't not require GetLastError() to be called
[DllImport(PinvokeDllNames.FindFirstFileDllName, EntryPoint = "FindFirstFileExW", SetLastError = false, CharSet = CharSet.Unicode)]
[DllImport(PinvokeDllNames.FindFirstFileDllName, EntryPoint = "FindFirstFileExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern SafeFindHandle FindFirstFileEx(string lpFileName, FINDEX_INFO_LEVELS fInfoLevelId, ref WIN32_FIND_DATA lpFindFileData, FINDEX_SEARCH_OPS fSearchOp, IntPtr lpSearchFilter, int dwAdditionalFlags);

internal enum FINDEX_INFO_LEVELS : uint
Expand Down Expand Up @@ -8248,28 +8261,55 @@ internal static bool IsReparsePoint(FileSystemInfo fileInfo)
return fileInfo.Attributes.HasFlag(System.IO.FileAttributes.ReparsePoint);
}

internal static bool IsReparsePointWithTarget(FileSystemInfo fileInfo)
internal static bool IsReparsePointLikeSymlink(FileSystemInfo fileInfo)
{
if (!IsReparsePoint(fileInfo))
#if UNIX
// Reparse point on Unix is a symlink.
return IsReparsePoint(fileInfo);
#else
if (InternalTestHooks.OneDriveTestOn && fileInfo.Name == InternalTestHooks.OneDriveTestSymlinkName)
{
return false;
return !InternalTestHooks.OneDriveTestRecurseOn;
}
#if !UNIX
// It is a reparse point and we should check some reparse point tags.
var data = new WIN32_FIND_DATA();
using (var handle = FindFirstFileEx(fileInfo.FullName, FINDEX_INFO_LEVELS.FindExInfoBasic, ref data, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, 0))

WIN32_FIND_DATA data = default;
string fullPath = Path.TrimEndingDirectorySeparator(fileInfo.FullName);
if (fullPath.Length > MAX_PATH)
{
fullPath = PathUtils.EnsureExtendedPrefix(fullPath);
}

using (SafeFindHandle handle = FindFirstFileEx(fullPath, FINDEX_INFO_LEVELS.FindExInfoBasic, ref data, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, 0))
{
if (handle.IsInvalid)
{
// Our handle could be invalidated by something else touching the filesystem,
// so ensure we deal with that possibility here
int lastError = Marshal.GetLastWin32Error();
throw new Win32Exception(lastError);
}

// We already have the file attribute information from our Win32 call,
// so no need to take the expense of the FileInfo.FileAttributes call
const int FILE_ATTRIBUTE_REPARSE_POINT = 0x0400;
if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0)
{
// Not a reparse point.
return false;
}

// The name surrogate bit 0x20000000 is defined in https://docs.microsoft.com/windows/win32/fileio/reparse-point-tags
// Name surrogates (0x20000000) are reparse points that point to other named entities local to the filesystem
// (like symlinks and mount points).
// In the case of OneDrive, they are not name surrogates and would be safe to recurse into.
if (!handle.IsInvalid && (data.dwReserved0 & 0x20000000) == 0 && (data.dwReserved0 != IO_REPARSE_TAG_APPEXECLINK))
if ((data.dwReserved0 & 0x20000000) == 0 && (data.dwReserved0 != IO_REPARSE_TAG_APPEXECLINK))
{
return false;
}
}
#endif

return true;
#endif
}

internal static bool WinIsHardLink(FileSystemInfo fileInfo)
Expand Down
108 changes: 107 additions & 1 deletion src/System.Management.Automation/utils/PathUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Management.Automation.Internal;
using System.Runtime.CompilerServices;
using System.Text;

using System.Management.Automation.Internal;
using Dbg = System.Management.Automation.Diagnostics;

namespace System.Management.Automation
Expand Down Expand Up @@ -447,5 +448,110 @@ internal static bool TryDeleteFile(string filepath)

return false;
}

#region Helpers for long paths from .Net Runtime

// Code here is copied from .NET's internal path helper implementation:
// https://github.com/dotnet/runtime/blob/dcce0f56e10f5ac9539354b049341a2d7c0cdebf/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.Windows.cs
// It has been left as a verbatim copy.

internal static string EnsureExtendedPrefix(string path)
{
if (IsPartiallyQualified(path) || IsDevice(path))
return path;

// Given \\server\share in longpath becomes \\?\UNC\server\share
if (path.StartsWith(UncPathPrefix, StringComparison.OrdinalIgnoreCase))
return path.Insert(2, UncDevicePrefixToInsert);

return ExtendedDevicePathPrefix + path;
}

private const string ExtendedDevicePathPrefix = @"\\?\";
private const string UncPathPrefix = @"\\";
private const string UncDevicePrefixToInsert = @"?\UNC\";
private const string UncExtendedPathPrefix = @"\\?\UNC\";
private const string DevicePathPrefix = @"\\.\";

// \\?\, \\.\, \??\
private const int DevicePrefixLength = 4;

/// <summary>
/// Returns true if the given character is a valid drive letter
/// </summary>
private static bool IsValidDriveChar(char value)
{
return ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z'));
}

private static bool IsDevice(string path)
{
return IsExtended(path)
||
(
path.Length >= DevicePrefixLength
&& IsDirectorySeparator(path[0])
&& IsDirectorySeparator(path[1])
&& (path[2] == '.' || path[2] == '?')
&& IsDirectorySeparator(path[3])
);
}

private static bool IsExtended(string path)
{
return path.Length >= DevicePrefixLength
&& path[0] == '\\'
&& (path[1] == '\\' || path[1] == '?')
&& path[2] == '?'
&& path[3] == '\\';
}

/// <summary>
/// Returns true if the path specified is relative to the current drive or working directory.
/// Returns false if the path is fixed to a specific drive or UNC path. This method does no
/// validation of the path (URIs will be returned as relative as a result).
/// </summary>
/// <remarks>
/// Handles paths that use the alternate directory separator. It is a frequent mistake to
/// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case.
/// "C:a" is drive relative- meaning that it will be resolved against the current directory
/// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory
/// will not be used to modify the path).
/// </remarks>
private static bool IsPartiallyQualified(string path)
{
if (path.Length < 2)
{
// It isn't fixed, it must be relative. There is no way to specify a fixed
// path with one character (or less).
return true;
}

if (IsDirectorySeparator(path[0]))
{
// There is no valid way to specify a relative path with two initial slashes or
// \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\
return !(path[1] == '?' || IsDirectorySeparator(path[1]));
}

// The only way to specify a fixed path that doesn't begin with two slashes
// is the drive, colon, slash format- i.e. C:\
return !((path.Length >= 3)
&& (path[1] == Path.VolumeSeparatorChar)
&& IsDirectorySeparator(path[2])
// To match old behavior we'll check the drive character for validity as the path is technically
// not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream.
&& IsValidDriveChar(path[0]));
}
/// <summary>
/// True if the given character is a directory separator.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsDirectorySeparator(char c)
{
return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
}

#endregion
}
}
Loading
0