Clone files on OSX-like platforms when possible, instead of copying the whole file...
authorHamish Arblaster <hamarb123@gmail.com>
Tue, 13 Jun 2023 14:58:17 +0000 (00:58 +1000)
committerGitHub <noreply@github.com>
Tue, 13 Jun 2023 14:58:17 +0000 (16:58 +0200)
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Dan Moseley <danmose@microsoft.com>
src/libraries/Common/src/Interop/OSX/Interop.libc.cs
src/libraries/System.IO.FileSystem/tests/File/Copy.cs
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems
src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.TryCloneFile.OSX.cs [new file with mode: 0644]
src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs

index 44717c5..adf70d3 100644 (file)
@@ -44,5 +44,10 @@ internal static partial class Interop
                     handle.DangerousRelease();
             }
         }
+
+        [LibraryImport(Libraries.libc, EntryPoint = "clonefile", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)]
+        internal static unsafe partial int clonefile(string src, string dst, int flags);
+
+        internal const int CLONE_ACL = 0x0004;
     }
 }
index 8b1f1c2..4ab26c3 100644 (file)
@@ -352,6 +352,19 @@ namespace System.IO.Tests
             Assert.Throws<IOException>(() => Copy(testFileAlternateStream, testFile2 + alternateStream, overwrite: true));
         }
 
+        [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsFileLockingEnabled))]
+        public void CopyOntoLockedFile()
+        {
+            string testFileSource = GetTestFilePath();
+            string testFileDest = GetTestFilePath();
+            File.Create(testFileSource).Dispose();
+            File.Create(testFileDest).Dispose();
+            using (var stream = new FileStream(testFileDest, FileMode.Open, FileAccess.Read, FileShare.None))
+            {
+                Assert.Throws<IOException>(() => Copy(testFileSource, testFileDest, overwrite: true));
+            }
+        }
+
         [Fact]
         public void DestinationFileIsTruncatedWhenItsLargerThanSourceFile()
         {
index d80ad76..c7a6eed 100644 (file)
       <Link>Common\Interop\OSX\Interop.libc.cs</Link>
     </Compile>
     <Compile Include="$(MSBuildThisFileDirectory)System\IO\FileStatus.SetTimes.OSX.cs" />
+    <Compile Include="$(MSBuildThisFileDirectory)System\IO\FileSystem.TryCloneFile.OSX.cs" />
   </ItemGroup>
   <ItemGroup Condition="'$(IsiOSLike)' == 'true' or '$(IsOSXLike)' == 'true'">
     <Compile Include="$(CommonPath)Interop\OSX\System.Native\Interop.SearchPath.cs">
diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.TryCloneFile.OSX.cs b/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.TryCloneFile.OSX.cs
new file mode 100644 (file)
index 0000000..8bfb604
--- /dev/null
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Win32.SafeHandles;
+using System.Diagnostics;
+
+namespace System.IO
+{
+    internal static partial class FileSystem
+    {
+        static partial void TryCloneFile(string sourceFullPath, string destFullPath, bool overwrite, ref bool cloned)
+        {
+            // This helper function calls out to clonefile, and returns the error.
+            static bool TryCloneFile(string sourceFullPath, string destFullPath, int flags, out Interop.Error error)
+            {
+                if (Interop.@libc.clonefile(sourceFullPath, destFullPath, flags) == 0)
+                {
+                    // Success.
+                    error = Interop.Error.SUCCESS;
+                    return true;
+                }
+
+                error = Interop.Sys.GetLastError();
+                return false;
+            }
+
+            // Try to clone the file immediately, this will only succeed if the
+            // destination doesn't exist, so we don't worry about locking for this one.
+            int flags = Interop.@libc.CLONE_ACL;
+            Interop.Error error;
+            if (TryCloneFile(sourceFullPath, destFullPath, flags, out error))
+            {
+                cloned = true;
+                return;
+            }
+
+            // Some filesystems don't support ACLs, so may fail due to trying to copy ACLs.
+            // This will disable them and allow trying again (a maximum of 1 time).
+            if (error == Interop.Error.EINVAL)
+            {
+                flags = 0;
+                if (TryCloneFile(sourceFullPath, destFullPath, flags, out error))
+                {
+                    cloned = true;
+                    return;
+                }
+            }
+
+            // Try to delete the destination file if we're overwriting.
+            if (error == Interop.Error.EEXIST && overwrite)
+            {
+                // Delete the destination. This should fail on directories. Get a lock to the dest file to ensure we don't copy onto it when
+                // it's locked by something else, and then delete it. It should also fail if destination == source since it's already locked.
+                try
+                {
+                    using SafeFileHandle? dstHandle = SafeFileHandle.Open(destFullPath, FileMode.Open, FileAccess.ReadWrite,
+                        FileShare.None, FileOptions.None, preallocationSize: 0, createOpenException: CreateOpenExceptionForCopyFile);
+                    if (Interop.Sys.Unlink(destFullPath) < 0 &&
+                        Interop.Sys.GetLastError() != Interop.Error.ENOENT)
+                    {
+                        // Fall back to standard copy as an unexpected error has occurred.
+                        return;
+                    }
+                }
+                catch (FileNotFoundException)
+                {
+                    // We don't want to throw if it's just the file not existing, since we're trying to delete it.
+                }
+
+                // Try clonefile now we've deleted the destination file.
+                if (TryCloneFile(sourceFullPath, destFullPath, flags, out error))
+                {
+                    cloned = true;
+                    return;
+                }
+            }
+
+            if (error is Interop.Error.ENOTSUP // Check if it's not supported,
+                      or Interop.Error.EXDEV   // if files are on different filesystems,
+                      or Interop.Error.EEXIST) // or if the destination file still exists.
+            {
+                // Fall back to normal copy.
+                return;
+            }
+
+            // Throw the appropriate exception.
+            Debug.Assert(error != Interop.Error.EINVAL); // We shouldn't fail due to an invalid parameter.
+            Debug.Assert(error != Interop.Error.SUCCESS); // We shouldn't fail with success.
+            throw Interop.GetExceptionForIoErrno(error.Info(), destFullPath);
+        }
+    }
+}
index f28cd70..be6505d 100644 (file)
@@ -28,27 +28,38 @@ namespace System.IO
             UnixFileMode.OtherWrite |
             UnixFileMode.OtherExecute;
 
+        static partial void TryCloneFile(string sourceFullPath, string destFullPath, bool overwrite, ref bool cloned);
+
         public static void CopyFile(string sourceFullPath, string destFullPath, bool overwrite)
         {
             long fileLength;
             UnixFileMode filePermissions;
             using SafeFileHandle src = SafeFileHandle.OpenReadOnly(sourceFullPath, FileOptions.None, out fileLength, out filePermissions);
+
+            // Try to clone the file first.
+            bool cloned = false;
+            TryCloneFile(sourceFullPath, destFullPath, overwrite, ref cloned);
+            if (cloned)
+            {
+                return;
+            }
+
             using SafeFileHandle dst = SafeFileHandle.Open(destFullPath, overwrite ? FileMode.Create : FileMode.CreateNew,
                                             FileAccess.ReadWrite, FileShare.None, FileOptions.None, preallocationSize: 0, filePermissions,
-                                            CreateOpenException);
+                                            CreateOpenExceptionForCopyFile);
 
             Interop.CheckIo(Interop.Sys.CopyFile(src, dst, fileLength));
+        }
 
-            static Exception? CreateOpenException(Interop.ErrorInfo error, Interop.Sys.OpenFlags flags, string path)
+        private static Exception? CreateOpenExceptionForCopyFile(Interop.ErrorInfo error, Interop.Sys.OpenFlags flags, string path)
+        {
+            // If the destination path points to a directory, we throw to match Windows behaviour.
+            if (error.Error == Interop.Error.EEXIST && DirectoryExists(path))
             {
-                // If the destination path points to a directory, we throw to match Windows behaviour.
-                if (error.Error == Interop.Error.EEXIST && DirectoryExists(path))
-                {
-                    return new IOException(SR.Format(SR.Arg_FileIsDirectory_Name, path));
-                }
-
-                return null; // Let SafeFileHandle create the exception for this error.
+                return new IOException(SR.Format(SR.Arg_FileIsDirectory_Name, path));
             }
+
+            return null; // Let SafeFileHandle create the exception for this error.
         }
 
 #pragma warning disable IDE0060