Skip to content

Commit

Permalink
Initial implementation of fix for dotnet#77835 - cloning files on macOS
Browse files Browse the repository at this point in the history
• Use copyfile (with COPYFILE_CLONE_FORCE) when possible on macOS to clone the file while still keeping file locking logic intact
• Split common Unix logic into multiple functions that the macOS implementation uses parts of at different times
• Add string version of ResolveLinkTarget to save the allocation since part of the code needs it
• Need to add tests to check the file is actually cloned so we know if it works or not
  • Loading branch information
hamarb123 committed Dec 5, 2022
1 parent d47a24c commit a373c59
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 11 deletions.
4 changes: 4 additions & 0 deletions src/libraries/Common/src/Interop/OSX/Interop.libc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,9 @@ internal static unsafe int fsetattrlist(SafeHandle handle, AttrList* attrList, v
handle.DangerousRelease();
}
}

[LibraryImport(Libraries.libc, EntryPoint = "copyfile", SetLastError = true)]
internal static unsafe partial int copyfile(string from, string to, void* state, uint flags);
internal const uint COPYFILE_CLONE_FORCE = 0x02000000;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2300,6 +2300,7 @@
</ItemGroup>
<ItemGroup Condition="('$(TargetsUnix)' == 'true' or '$(TargetsBrowser)' == 'true') and '$(IsOSXLike)' != 'true'">
<Compile Include="$(MSBuildThisFileDirectory)System\IO\FileStatus.SetTimes.OtherUnix.cs" />
<Compile Include="$(MSBuildThisFileDirectory)System\IO\FileSystem.CopyFile.OtherUnix.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetsUnix)' == 'true'">
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.GetEUid.cs" Link="Common\Interop\Unix\Interop.GetEUid.cs" />
Expand Down Expand Up @@ -2353,6 +2354,7 @@
<Link>Common\Interop\OSX\Interop.libc.cs</Link>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)System\IO\FileStatus.SetTimes.OSX.cs" />
<Compile Include="$(MSBuildThisFileDirectory)System\IO\FileSystem.CopyFile.OSX.cs" />
</ItemGroup>
<ItemGroup Condition="'$(IsiOSLike)' == 'true' or '$(IsOSXLike)' == 'true'">
<Compile Include="$(CommonPath)Interop\OSX\System.Native\Interop.SearchPath.cs">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.InteropServices;

namespace System.IO
{
partial class FileSystem
{
public static unsafe void CopyFile(string sourceFullPath, string destFullPath, bool overwrite)
{
//Attempt to clone the file:

//Get the full path of the source path
string fullSource = TryGetLinkTarget(sourceFullPath) ?? sourceFullPath;

//Start the file copy and prepare for finalization
StartedCopyFileState startedCopyFile = StartCopyFile(fullSource, destFullPath, overwrite, openDst: false);

//Attempt counter just in case we somehow loop infinite times e.g. on a
//filesystem that doesn't actually delete files but pretends it does.
//Declare error variable here since it can be used after some jumping around.
int attempts = 0;
int error;

try
{
//Don't need to re-read the link on our first attempt
bool failOnRereadDoesntChange = false;
if (overwrite)
{
//Ensure file is deleted on first try.
//Get a lock to the dest file for compat reasons, and then delete it.
using SafeFileHandle? dstHandle = OpenCopyFileDstHandle(destFullPath, true, startedCopyFile, false);
File.Delete(destFullPath);
}
goto tryAgain;

//We may want to re-read the link to see if its path has changed
tryAgainWithReadLink:
if (++attempts >= 5) goto throwError;
string fullSource2 = TryGetLinkTarget(sourceFullPath) ?? sourceFullPath;
if (fullSource != fullSource2)
{
//Path has changed
startedCopyFile.Dispose();
startedCopyFile = StartCopyFile(fullSource, destFullPath, overwrite, openDst: false);
}
else if (failOnRereadDoesntChange)
{
//Path hasn't changed and we want to throw the error we got earlier
goto throwError;
}
failOnRereadDoesntChange = false;

//Attempt to clone the file
tryAgain:
if (Interop.@libc.copyfile(fullSource, destFullPath, null, Interop.@libc.COPYFILE_CLONE_FORCE) == 0)
{
return;
}

//Check the error
error = Marshal.GetLastWin32Error();
const int ENOTSUP = 45;
const int EEXIST = 17;
const int ENOENT = 2;
bool directoryExist = false;
if ((error == ENOTSUP && FileOrDirectoryExists(destFullPath)) || error == EEXIST)
{
//This means the destination existed, try again with the destination deleted if appropriate
error = EEXIST;
if (Directory.Exists(destFullPath))
{
directoryExist = true;
goto throwError;
}
if (overwrite)
{
//Get a lock to the dest file for compat reasons, and then delete it.
using SafeFileHandle dstHandle = OpenCopyFileDstHandle(destFullPath, true, startedCopyFile);
File.Delete(destFullPath);
goto tryAgainWithReadLink;
}
}
else if (error == ENOTSUP)
{
//This probably means cloning is not supported, try the standard implementation
goto fallback;
}
else if (error == ENOENT)
{
//This can happen if the source is a symlink and it has been changed to a different file, and the first has been deleted or renamed, for example.
//failOnRereadDoesntChange means we want to fail if the link didn't change, indicating the source actually doesn't exist.
failOnRereadDoesntChange = true;
goto tryAgainWithReadLink;
}

//Throw an appropriate error
throwError:
if (directoryExist)
{
throw new IOException(SR.Format(SR.Arg_FileIsDirectory_Name, path));
}
throw Interop.GetExceptionForIoErrno(new ErrorInfo(error));

//Fallback to the standard unix implementation for when cloning is not supported
fallback:

//Open the dst handle
startedCopyFile.dst = OpenCopyFileDstHandle(destFullPath, overwrite, startedCopyFile, true);

//Copy the file using the standard unix implementation
StandardCopyFile(startedCopyFile);
}
finally
{
startedCopyFile.Dispose();
}

//Attempts to read the path's link target, or returns null even if the path doesn't exist
static string? TryGetLinkTarget(string path)
{
try
{
return ResolveLinkTargetString(sourceFullPath, true, false);
}
catch
{
return null;
}
}

//Checks if a file or directory exists without caring which it was
static bool FileOrDirectoryExists(string path)
{
return Interop.Sys.Stat(fullPath, out _) >= 0;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.IO
{
partial class FileSystem
{
public static void CopyFile(string sourceFullPath, string destFullPath, bool overwrite)
{
//Start the file copy and prepare for finalization
using StartedCopyFileState startedCopyFile = StartCopyFile(sourceFullPath, destFullPath, overwrite);

//Copy the file using the standard unix implementation
StandardCopyFile(startCopyFile);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,72 @@ internal static partial class FileSystem
UnixFileMode.OtherWrite |
UnixFileMode.OtherExecute;

public static void CopyFile(string sourceFullPath, string destFullPath, bool overwrite)
//Helper type to facilitate returning values from StartCopyFile without having
//to declare a massive tuple multiple times, and making it easier to dispose.
private struct StartedCopyFileState : IDisposable
{
long fileLength;
UnixFileMode filePermissions;
using SafeFileHandle src = SafeFileHandle.OpenReadOnly(sourceFullPath, FileOptions.None, out fileLength, out filePermissions);
using SafeFileHandle dst = SafeFileHandle.Open(destFullPath, overwrite ? FileMode.Create : FileMode.CreateNew,
FileAccess.ReadWrite, FileShare.None, FileOptions.None, preallocationSize: 0, filePermissions,
CreateOpenException);
public long fileLength;
public UnixFileMode filePermissions;
public SafeFileHandle? src;
public SafeFileHandle? dst;

Interop.CheckIo(Interop.Sys.CopyFile(src, dst, fileLength));
public StartedCopyFileState(long fileLength, UnixFileMode filePermissions, SafeFileHandle src, SafeFileHandle dst)
{
this.fileLength = fileLength;
this.filePermissions = filePermissions;
this.src = src;
this.dst = dst;
}

public void Dispose()
{
src?.Dispose();
dst?.Dispose();
}
}

private static StartedCopyFileState StartCopyFile(string sourceFullPath, string destFullPath, bool overwrite)
{
//The return value is expected to be Disposed by the caller (unless this method throws) once the copy is complete.
//Begins 'CopyFile' by locking and creating the relevant file handles.

StartedCopyFileState startedCopyFile = default;
try
{
startedCopyFile.src = SafeFileHandle.OpenReadOnly(sourceFullPath, FileOptions.None, out startedCopyFile.fileLength, out startedCopyFile.filePermissions);
startedCopyFile.dst = OpenCopyFileDstHandle(destFullPath, overwrite, startedCopyFile, true);
}
catch
{
startedCopyFile.Dispose();
throw;
}

//Return the collection of information we have gotten back.
return startedCopyFile;
}

private static SafeFileHandle? OpenCopyFileDstHandle(string destFullPath, bool overwrite, StartedCopyFileState startedCopyFile, bool openNewFile)
{
//This function opens the 'dst' file handle for 'CopyFile', it is
//split out since the logic on OSX-like OSes is a bit different.
//'openNewFile' = false is used when we want to try to find the file only.
if (!openNewFile)
{
try
{
return SafeFileHandle.Open(destFullPath, FileMode.Open,
FileAccess.ReadWrite, FileShare.None, FileOptions.None, preallocationSize: 0, startedCopyFile.filePermissions,
CreateOpenException);
}
catch (FileNotFoundException)
{
return null;
}
}
return SafeFileHandle.Open(destFullPath, overwrite ? FileMode.Create : FileMode.CreateNew,
FileAccess.ReadWrite, FileShare.None, FileOptions.None, preallocationSize: 0, unixCreateMode: null,
CreateOpenException);

static Exception? CreateOpenException(Interop.ErrorInfo error, Interop.Sys.OpenFlags flags, string path)
{
Expand All @@ -51,6 +107,18 @@ public static void CopyFile(string sourceFullPath, string destFullPath, bool ove
}
}

private static void StandardCopyFile(StartedCopyFileState startedCopyFile)
{
//Copy the file in a way that works on all Unix Operating Systems.
//The 'startedCopyFile' parameter should take the output from 'StartCopyFile'.
//'startedCopyFile' should be disposed by the caller.
Interop.CheckIo(Interop.Sys.CopyFile(startedCopyFile.src, startedCopyFile.dst, startedCopyFile.fileLength));
}

//CopyFile is defined in either FileSystem.CopyFile.OSX.cs or FileSystem.CopyFile.OtherUnix.cs
//The implementations on OSX-like Operating Systems attempts to clone the file first.
static partial void CopyFile(string sourceFullPath, string destFullPath, bool overwrite);

#pragma warning disable IDE0060
public static void Encrypt(string path)
{
Expand Down Expand Up @@ -659,6 +727,16 @@ internal static void CreateSymbolicLink(string path, string pathToTarget, bool i
#pragma warning restore IDE0060

internal static FileSystemInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget, bool isDirectory)
{
string? linkTarget = ResolveLinkTargetString(linkPath, returnFinalTarget, isDirectory);
if (linkTarget == null) return null;

return isDirectory ?
new DirectoryInfo(linkTarget) :
new FileInfo(linkTarget);
}

private static string? ResolveLinkTargetString(string linkPath, bool returnFinalTarget, bool isDirectory)
{
ValueStringBuilder sb = new(Interop.DefaultPathBufferSize);
sb.Append(linkPath);
Expand Down Expand Up @@ -704,9 +782,7 @@ internal static void CreateSymbolicLink(string path, string pathToTarget, bool i
Debug.Assert(sb.Length > 0);
linkTarget = sb.ToString(); // ToString disposes

return isDirectory ?
new DirectoryInfo(linkTarget) :
new FileInfo(linkTarget);
return linkTarget;

// In case of link target being relative:
// Preserve the full path of the directory of the previous path
Expand Down

0 comments on commit a373c59

Please sign in to comment.