Skip to content

Instantly share code, notes, and snippets.

@Cologler
Last active August 25, 2020 13:40
Show Gist options
  • Save Cologler/e631f124437ae79fd321b092fd865200 to your computer and use it in GitHub Desktop.
Save Cologler/e631f124437ae79fd321b092fd865200 to your computer and use it in GitHub Desktop.
atomic write file for C#
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace Utils
{
/// <summary>
///
/// </summary>
/// <remarks>
/// Interop and PathInternal are clone and modify was from dotnet core runtime.
/// </remarks>
public class AtomicWriteFileStream : Stream
{
private bool _isDisposed;
private readonly string _path;
private readonly string _tempFileName;
private readonly FileStream _tempFileStream;
private readonly bool _overwrite;
private static (string, FileStream) OpenRandomFile(string directory)
{
var random = new Random();
var path = Path.Combine(directory, random.Next().ToString());
var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write);
return (path, stream);
}
public AtomicWriteFileStream(string path, bool overwrite = false)
{
if (path is null)
throw new ArgumentNullException(nameof(path));
this._path = Path.GetFullPath(path);
(this._tempFileName, this._tempFileStream) = OpenRandomFile(Path.GetDirectoryName(this._path));
this._overwrite = overwrite;
}
public override bool CanRead => this._tempFileStream.CanRead;
public override bool CanSeek => this._tempFileStream.CanSeek;
public override bool CanWrite => this._tempFileStream.CanWrite;
public override long Length => this._tempFileStream.Length;
public override long Position
{
get => this._tempFileStream.Position;
set => this._tempFileStream.Position = value;
}
public override void Flush() => this._tempFileStream.Flush();
public override int Read(byte[] buffer, int offset, int count) => this._tempFileStream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => this._tempFileStream.Seek(offset, origin);
public override void SetLength(long value) => this._tempFileStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => this._tempFileStream.Write(buffer, offset, count);
public override void Close()
{
base.Close();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (!this._isDisposed)
{
this._tempFileStream.Flush(true);
this._tempFileStream.Dispose();
try
{
MoveFile(this._tempFileName, this._path, this._overwrite);
}
catch
{
try
{
File.Delete(this._tempFileName);
}
catch { }
throw;
}
this._isDisposed = true;
}
}
base.Dispose(disposing);
}
private static void MoveFile(string sourceFullPath, string destFullPath, bool overwrite)
{
if (!Interop.Kernel32.MoveFile(sourceFullPath, destFullPath, overwrite))
{
throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error());
}
}
private class Interop
{
public class Kernel32
{
private const uint MOVEFILE_REPLACE_EXISTING = 0x01;
private const uint MOVEFILE_WRITE_THROUGH = 0x08;
/// <summary>
/// WARNING: This method does not implicitly handle long paths. Use MoveFile.
/// </summary>
[DllImport("kernel32.dll", EntryPoint = "MoveFileExW", SetLastError = true, CharSet = CharSet.Unicode, BestFitMapping = false)]
private static extern bool MoveFileExW(string src, string dst, uint flags);
/// <summary>
/// Moves a file or directory, optionally overwriting existing destination file. NOTE: overwrite must be false for directories.
/// </summary>
/// <param name="src">Source file or directory</param>
/// <param name="dst">Destination file or directory</param>
/// <param name="overwrite">True to overwrite existing destination file. NOTE: must pass false for directories as overwrite of directories is not supported.</param>
/// <returns></returns>
public static bool MoveFile(string src, string dst, bool overwrite)
{
src = PathInternal.EnsureExtendedPrefixIfNeeded(src);
dst = PathInternal.EnsureExtendedPrefixIfNeeded(dst);
var flags = MOVEFILE_WRITE_THROUGH;
if (overwrite)
{
flags |= MOVEFILE_REPLACE_EXISTING;
}
return MoveFileExW(src, dst, flags);
}
}
}
/// <summary>Contains internal path helpers that are shared between many projects.</summary>
private static class PathInternal
{
// All paths in Win32 ultimately end up becoming a path to a File object in the Windows object manager. Passed in paths get mapped through
// DosDevice symbolic links in the object tree to actual File objects under \Devices. To illustrate, this is what happens with a typical
// path "Foo" passed as a filename to any Win32 API:
//
// 1. "Foo" is recognized as a relative path and is appended to the current directory (say, "C:\" in our example)
// 2. "C:\Foo" is prepended with the DosDevice namespace "\??\"
// 3. CreateFile tries to create an object handle to the requested file "\??\C:\Foo"
// 4. The Object Manager recognizes the DosDevices prefix and looks
// a. First in the current session DosDevices ("\Sessions\1\DosDevices\" for example, mapped network drives go here)
// b. If not found in the session, it looks in the Global DosDevices ("\GLOBAL??\")
// 5. "C:" is found in DosDevices (in our case "\GLOBAL??\C:", which is a symbolic link to "\Device\HarddiskVolume6")
// 6. The full path is now "\Device\HarddiskVolume6\Foo", "\Device\HarddiskVolume6" is a File object and parsing is handed off
// to the registered parsing method for Files
// 7. The registered open method for File objects is invoked to create the file handle which is then returned
//
// There are multiple ways to directly specify a DosDevices path. The final format of "\??\" is one way. It can also be specified
// as "\\.\" (the most commonly documented way) and "\\?\". If the question mark syntax is used the path will skip normalization
// (essentially GetFullPathName()) and path length checks.
// Windows Kernel-Mode Object Manager
// https://msdn.microsoft.com/en-us/library/windows/hardware/ff565763.aspx
// https://channel9.msdn.com/Shows/Going+Deep/Windows-NT-Object-Manager
//
// Introduction to MS-DOS Device Names
// https://msdn.microsoft.com/en-us/library/windows/hardware/ff548088.aspx
//
// Local and Global MS-DOS Device Names
// https://msdn.microsoft.com/en-us/library/windows/hardware/ff554302.aspx
internal const string ExtendedDevicePathPrefix = @"\\?\";
internal const string UncPathPrefix = @"\\";
internal const string UncDevicePrefixToInsert = @"?\UNC\";
internal const string UncExtendedPathPrefix = @"\\?\UNC\";
internal const string DevicePathPrefix = @"\\.\";
internal const int MaxShortPath = 260;
// \\?\, \\.\, \??\
internal const int DevicePrefixLength = 4;
/// <summary>
/// Returns true if the given character is a valid drive letter
/// </summary>
internal static bool IsValidDriveChar(char value)
{
return ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z'));
}
private static bool EndsWithPeriodOrSpace(string path)
{
if (string.IsNullOrEmpty(path))
return false;
char c = path[path.Length - 1];
return c == ' ' || c == '.';
}
/// <summary>
/// Adds the extended path prefix (\\?\) if not already a device path, IF the path is not relative,
/// AND the path is more than 259 characters. (> MAX_PATH + null). This will also insert the extended
/// prefix if the path ends with a period or a space. Trailing periods and spaces are normally eaten
/// away from paths during normalization, but if we see such a path at this point it should be
/// normalized and has retained the final characters. (Typically from one of the *Info classes)
/// </summary>
internal static string EnsureExtendedPrefixIfNeeded(string path)
{
if (path != null && (path.Length >= MaxShortPath || EndsWithPeriodOrSpace(path)))
{
return EnsureExtendedPrefix(path);
}
else
{
return path;
}
}
/// <summary>
/// Adds the extended path prefix (\\?\) if not relative or already a device path.
/// </summary>
internal static string EnsureExtendedPrefix(string path)
{
// Putting the extended prefix on the path changes the processing of the path. It won't get normalized, which
// means adding to relative paths will prevent them from getting the appropriate current directory inserted.
// If it already has some variant of a device path (\??\, \\?\, \\.\, //./, etc.) we don't need to change it
// as it is either correct or we will be changing the behavior. When/if Windows supports long paths implicitly
// in the future we wouldn't want normalization to come back and break existing code.
// In any case, all internal usages should be hitting normalize path (Path.GetFullPath) before they hit this
// shimming method. (Or making a change that doesn't impact normalization, such as adding a filename to a
// normalized base 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;
}
/// <summary>
/// Returns true if the path uses any of the DOS device path syntaxes. ("\\.\", "\\?\", or "\??\")
/// </summary>
internal static bool IsDevice(string path)
{
// If the path begins with any two separators is will be recognized and normalized and prepped with
// "\??\" for internal usage correctly. "\??\" is recognized and handled, "/??/" is not.
return IsExtended(path)
||
(
path.Length >= DevicePrefixLength
&& IsDirectorySeparator(path[0])
&& IsDirectorySeparator(path[1])
&& (path[2] == '.' || path[2] == '?')
&& IsDirectorySeparator(path[3])
);
}
/// <summary>
/// Returns true if the path uses the canonical form of extended syntax ("\\?\" or "\??\"). If the
/// path matches exactly (cannot use alternate directory separators) Windows will skip normalization
/// and path length checks.
/// </summary>
internal static bool IsExtended(string path)
{
// While paths like "//?/C:/" will work, they're treated the same as "\\.\" paths.
// Skipping of normalization will *only* occur if back slashes ('\') are used.
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>
internal 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)]
internal static bool IsDirectorySeparator(char c)
{
return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
}
}
#region override from file stream
public override IAsyncResult BeginRead(byte[] array, int offset, int numBytes, AsyncCallback userCallback, object stateObject)
=> this._tempFileStream.BeginRead(array, offset, numBytes, userCallback, stateObject);
public override IAsyncResult BeginWrite(byte[] array, int offset, int numBytes, AsyncCallback userCallback, object stateObject)
=> this._tempFileStream.BeginWrite(array, offset, numBytes, userCallback, stateObject);
public override int EndRead(IAsyncResult asyncResult)
=> this._tempFileStream.EndRead(asyncResult);
public override void EndWrite(IAsyncResult asyncResult)
=> this._tempFileStream.EndWrite(asyncResult);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> this._tempFileStream.ReadAsync(buffer, offset, count, cancellationToken);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> this._tempFileStream.WriteAsync(buffer, offset, count, cancellationToken);
#endregion
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment