diff --git a/Duplicati/Library/Common/IO/SystemIOWindows.cs b/Duplicati/Library/Common/IO/SystemIOWindows.cs index 0a1bacde6..423afa317 100644 --- a/Duplicati/Library/Common/IO/SystemIOWindows.cs +++ b/Duplicati/Library/Common/IO/SystemIOWindows.cs @@ -1,627 +1,632 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.Security.AccessControl; -using System.IO; -using System.Linq; - -using Duplicati.Library.Interface; -using Newtonsoft.Json; -using System.Runtime.Versioning; -using System.Security.Principal; - -namespace Duplicati.Library.Common.IO -{ - [SupportedOSPlatform("windows")] - public struct SystemIOWindows : ISystemIO - { - // Based on the constant names used in - // https://github.com/dotnet/runtime/blob/v5.0.12/src/libraries/Common/src/System/IO/PathInternal.Windows.cs - private const string ExtendedDevicePathPrefix = @"\\?\"; - private const string UncPathPrefix = @"\\"; - private const string AltUncPathPrefix = @"//"; - private const string UncExtendedPathPrefix = @"\\?\UNC\"; - - private static readonly string DIRSEP = Util.DirectorySeparatorString; - - /// - /// The current user name - /// - private static readonly string CURRENT_USERNAME = WindowsIdentity.GetCurrent().Name; - - /// - /// The LocalSystem user name - /// - private static readonly string LOCAL_SYSTEM_NAME = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Translate(typeof(NTAccount)).Value; - - /// - /// Prefix path with one of the extended device path prefixes - /// (@"\\?\" or @"\\?\UNC\") but only if it's a fully qualified - /// path with no relative components (i.e., with no "." or ".." - /// as part of the path). - /// - public static string AddExtendedDevicePathPrefix(string path) - { - if (IsPrefixedWithExtendedDevicePathPrefix(path)) - { - // For example: \\?\C:\Temp\foo.txt or \\?\UNC\example.com\share\foo.txt - return path; - } - else - { - var hasRelativePathComponents = HasRelativePathComponents(path); - if (IsPrefixedWithUncPathPrefix(path) && !hasRelativePathComponents) - { - // For example: \\example.com\share\foo.txt or //example.com/share/foo.txt - return UncExtendedPathPrefix + ConvertSlashes(path.Substring(UncPathPrefix.Length)); - } - else if (DotNetRuntimePathWindows.IsPathFullyQualified(path) && !hasRelativePathComponents) - { - // For example: C:\Temp\foo.txt or C:/Temp/foo.txt - return ExtendedDevicePathPrefix + ConvertSlashes(path); - } - else - { - // A relative path or a fully qualified path with relative - // path components so the extended device path prefixes - // cannot be applied. - // - // For example: foo.txt or C:\Temp\..\foo.txt - return path; - } - } - } - - /// - /// Returns true if prefixed with @"\\" or @"//". - /// - private static bool IsPrefixedWithUncPathPrefix(string path) - { - return path.StartsWith(UncPathPrefix, StringComparison.Ordinal) || - path.StartsWith(AltUncPathPrefix, StringComparison.Ordinal); - } - - /// - /// Returns true if prefixed with @"\\?\UNC\" or @"\\?\". - /// - private static bool IsPrefixedWithExtendedDevicePathPrefix(string path) - { - return path.StartsWith(UncExtendedPathPrefix, StringComparison.Ordinal) || - path.StartsWith(ExtendedDevicePathPrefix, StringComparison.Ordinal); - } - - private static string[] relativePathComponents = new[] { ".", ".." }; - - /// - /// Returns true if contains relative path components; i.e., "." or "..". - /// - private static bool HasRelativePathComponents(string path) - { - return GetPathComponents(path).Any(pathComponent => relativePathComponents.Contains(pathComponent)); - } - - /// - /// Returns a sequence representing the files and directories in . - /// - private static IEnumerable GetPathComponents(string path) - { - while (!String.IsNullOrEmpty(path)) - { - var pathComponent = Path.GetFileName(path); - if (!String.IsNullOrEmpty(pathComponent)) - { - yield return pathComponent; - } - path = Path.GetDirectoryName(path); - } - } - - /// - /// Removes either of the extended device path prefixes - /// (@"\\?\" or @"\\?\UNC\") if is prefixed - /// with one of them. - /// - public static string RemoveExtendedDevicePathPrefix(string path) - { - if (path.StartsWith(UncExtendedPathPrefix, StringComparison.Ordinal)) - { - // @"\\?\UNC\example.com\share\file.txt" to @"\\example.com\share\file.txt" - return UncPathPrefix + path.Substring(UncExtendedPathPrefix.Length); - } - else if (path.StartsWith(ExtendedDevicePathPrefix, StringComparison.Ordinal)) - { - // @"\\?\C:\file.txt" to @"C:\file.txt" - return path.Substring(ExtendedDevicePathPrefix.Length); - } - else - { - return path; - } - } - - /// - /// Convert forward slashes to backslashes. - /// - /// Path with forward slashes replaced by backslashes. - private static string ConvertSlashes(string path) - { - return path.Replace("/", Util.DirectorySeparatorString); - } - - - [SupportedOSPlatform("windows")] - private class FileSystemAccess - { - // Use JsonProperty Attribute to allow readonly fields to be set by deserializer - // https://github.com/duplicati/duplicati/issues/4028 - [JsonProperty] - public readonly FileSystemRights Rights; - [JsonProperty] - public readonly AccessControlType ControlType; - [JsonProperty] - public readonly string SID; - [JsonProperty] - public readonly bool Inherited; - [JsonProperty] - public readonly InheritanceFlags Inheritance; - [JsonProperty] - public readonly PropagationFlags Propagation; - - public FileSystemAccess() - { - } - - public FileSystemAccess(FileSystemAccessRule rule) - { - Rights = rule.FileSystemRights; - ControlType = rule.AccessControlType; - SID = rule.IdentityReference.Value; - Inherited = rule.IsInherited; - Inheritance = rule.InheritanceFlags; - Propagation = rule.PropagationFlags; - } - - public FileSystemAccessRule Create(FileSystemSecurity owner) - { - return (FileSystemAccessRule)owner.AccessRuleFactory( - new System.Security.Principal.SecurityIdentifier(SID), - (int)Rights, - Inherited, - Inheritance, - Propagation, - ControlType); - } - } - - private static JsonSerializer _cachedSerializer; - - private JsonSerializer Serializer - { - get - { - if (_cachedSerializer != null) - { - return _cachedSerializer; - } - - _cachedSerializer = JsonSerializer.Create( - new JsonSerializerSettings { Culture = System.Globalization.CultureInfo.InvariantCulture }); - - return _cachedSerializer; - } - } - - private string SerializeObject(T o) - { - using (var tw = new StringWriter()) - { - Serializer.Serialize(tw, o); - tw.Flush(); - return tw.ToString(); - } - } - - private T DeserializeObject(string data) - { - using (var tr = new StringReader(data)) - return (T)Serializer.Deserialize(tr, typeof(T)); - } - - - [SupportedOSPlatform("windows")] - private FileSystemSecurity GetAccessControlDir(string path) - => new DirectoryInfo(AddExtendedDevicePathPrefix(path)).GetAccessControl(); - - - [SupportedOSPlatform("windows")] - private FileSystemSecurity GetAccessControlFile(string path) - => new FileInfo(AddExtendedDevicePathPrefix(path)).GetAccessControl(); - - - [SupportedOSPlatform("windows")] - private void SetAccessControlFile(string path, FileSecurity rules) - => new FileInfo(AddExtendedDevicePathPrefix(path)).SetAccessControl(rules); - - [SupportedOSPlatform("windows")] - private void SetAccessControlDir(string path, DirectorySecurity rules) - => new DirectoryInfo(AddExtendedDevicePathPrefix(path)).SetAccessControl(rules); - - #region ISystemIO implementation - public void DirectoryCreate(string path) - => Directory.CreateDirectory(AddExtendedDevicePathPrefix(path)); - - public void DirectoryDelete(string path, bool recursive) - => Directory.Delete(AddExtendedDevicePathPrefix(path), recursive); - - public bool DirectoryExists(string path) - => Directory.Exists(AddExtendedDevicePathPrefix(path)); - - public void DirectoryMove(string sourceDirName, string destDirName) - => Directory.Move(AddExtendedDevicePathPrefix(sourceDirName), AddExtendedDevicePathPrefix(destDirName)); - - public void FileDelete(string path) - => File.Delete(AddExtendedDevicePathPrefix(path)); - - public void FileSetLastWriteTimeUtc(string path, DateTime time) - => File.SetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path), time); - - public void FileSetCreationTimeUtc(string path, DateTime time) - => File.SetCreationTimeUtc(AddExtendedDevicePathPrefix(path), time); - - public DateTime FileGetLastWriteTimeUtc(string path) - => File.GetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path)); - - public DateTime FileGetCreationTimeUtc(string path) - => File.GetCreationTimeUtc(AddExtendedDevicePathPrefix(path)); - - public bool FileExists(string path) - => File.Exists(AddExtendedDevicePathPrefix(path)); - - public FileStream FileOpenRead(string path) - => File.Open(AddExtendedDevicePathPrefix(path), FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - public FileStream FileOpenReadWrite(string path) - => !FileExists(path) - ? FileCreate(path) - : File.Open(AddExtendedDevicePathPrefix(path), FileMode.Open, FileAccess.ReadWrite); - - public FileStream FileOpenWrite(string path) - => !FileExists(path) - ? FileCreate(path) - : File.OpenWrite(AddExtendedDevicePathPrefix(path)); - - public FileStream FileCreate(string path) - => File.Create(AddExtendedDevicePathPrefix(path)); - - public FileAttributes GetFileAttributes(string path) - => File.GetAttributes(AddExtendedDevicePathPrefix(path)); - - public void SetFileAttributes(string path, FileAttributes attributes) - { - File.SetAttributes(AddExtendedDevicePathPrefix(path), attributes); - } - - /// - /// Returns the symlink target if the entry is a symlink, and null otherwise - /// - /// The file or folder to examine - /// The symlink target - public string GetSymlinkTarget(string file) - => new FileInfo(AddExtendedDevicePathPrefix(file)).LinkTarget; - - public IEnumerable EnumerateFileSystemEntries(string path) - => Directory.EnumerateFileSystemEntries(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix); - - public IEnumerable EnumerateFiles(string path) - => Directory.EnumerateFiles(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix); - - public IEnumerable EnumerateFiles(string path, string searchPattern, SearchOption searchOption) - => Directory.EnumerateFiles(AddExtendedDevicePathPrefix(path), searchPattern, searchOption).Select(RemoveExtendedDevicePathPrefix); - - public string PathGetFileName(string path) - => RemoveExtendedDevicePathPrefix(Path.GetFileName(AddExtendedDevicePathPrefix(path))); - - public string PathGetDirectoryName(string path) - => RemoveExtendedDevicePathPrefix(Path.GetDirectoryName(AddExtendedDevicePathPrefix(path))); - - public string PathGetExtension(string path) - => RemoveExtendedDevicePathPrefix(Path.GetExtension(AddExtendedDevicePathPrefix(path))); - - public string PathChangeExtension(string path, string extension) - => RemoveExtendedDevicePathPrefix(Path.ChangeExtension(AddExtendedDevicePathPrefix(path), extension)); - - public void DirectorySetLastWriteTimeUtc(string path, DateTime time) - { - Directory.SetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path), time); - } - - public void DirectorySetCreationTimeUtc(string path, DateTime time) - { - Directory.SetCreationTimeUtc(AddExtendedDevicePathPrefix(path), time); - } - - public void FileMove(string source, string target) - { - File.Move(AddExtendedDevicePathPrefix(source), AddExtendedDevicePathPrefix(target)); - } - - public long FileLength(string path) - => new FileInfo(AddExtendedDevicePathPrefix(path)).Length; - - public string GetPathRoot(string path) - => IsPrefixedWithExtendedDevicePathPrefix(path) - ? Path.GetPathRoot(path) - : RemoveExtendedDevicePathPrefix(Path.GetPathRoot(AddExtendedDevicePathPrefix(path))); - public string[] GetDirectories(string path) - => IsPrefixedWithExtendedDevicePathPrefix(path) - ? Directory.GetDirectories(path) - : Directory.GetDirectories(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix).ToArray(); - - public string[] GetFiles(string path) - => IsPrefixedWithExtendedDevicePathPrefix(path) - ? Directory.GetFiles(path) - : Directory.GetFiles(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix).ToArray(); - - public string[] GetFiles(string path, string searchPattern) - => IsPrefixedWithExtendedDevicePathPrefix(path) - ? Directory.GetFiles(path, searchPattern) - : Directory.GetFiles(AddExtendedDevicePathPrefix(path), searchPattern).Select(RemoveExtendedDevicePathPrefix).ToArray(); - - public DateTime GetCreationTimeUtc(string path) - => Directory.GetCreationTimeUtc(AddExtendedDevicePathPrefix(path)); - - public DateTime GetLastWriteTimeUtc(string path) - => Directory.GetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path)); - - public IEnumerable EnumerateDirectories(string path) - => IsPrefixedWithExtendedDevicePathPrefix(path) - ? Directory.EnumerateDirectories(path) - : Directory.EnumerateDirectories(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix); - - public IEnumerable EnumerateFileEntries(string path) - { - // For consistency with previous implementation, enumerate files first and directories after - var dir = IsPrefixedWithExtendedDevicePathPrefix(path) - ? new DirectoryInfo(path) - : new DirectoryInfo(AddExtendedDevicePathPrefix(path)); - - foreach (FileInfo file in dir.EnumerateFiles()) - yield return FileEntry(file); - - foreach (DirectoryInfo d in dir.EnumerateDirectories()) - yield return DirectoryEntry(d); - } - - public void FileCopy(string source, string target, bool overwrite) - => File.Copy(AddExtendedDevicePathPrefix(source), AddExtendedDevicePathPrefix(target), overwrite); - - public string PathGetFullPath(string path) - { - // Desired behavior: - // 1. If path is already prefixed with \\?\, it should be left untouched - // 2. If path is not already prefixed with \\?\, the return value should also not be prefixed - // 3. If path is relative or has relative components, that should be resolved by calling Path.GetFullPath() - // 4. If path is not relative and has no relative components, prefix with \\?\ to prevent normalization from munging "problematic Windows paths" - return IsPrefixedWithExtendedDevicePathPrefix(path) - ? path - : RemoveExtendedDevicePathPrefix(Path.GetFullPath(AddExtendedDevicePathPrefix(path))); - } - - public IFileEntry DirectoryEntry(string path) - => DirectoryEntry(new DirectoryInfo(AddExtendedDevicePathPrefix(path))); - - public IFileEntry DirectoryEntry(DirectoryInfo dInfo) - => new FileEntry(dInfo.Name, 0, dInfo.LastAccessTime, dInfo.LastWriteTime) - { - IsFolder = true - }; - - public IFileEntry FileEntry(string path) - => FileEntry(new FileInfo(AddExtendedDevicePathPrefix(path))); - - public IFileEntry FileEntry(FileInfo fileInfo) - { - var lastAccess = new DateTime(); - try - { - // Internally this will convert the FILETIME value from Windows API to a - // DateTime. If the value represents a date after 12/31/9999 it will throw - // ArgumentOutOfRangeException, because this is not supported by DateTime. - // Some file systems seem to set strange access timestamps on files, which - // may lead to this exception being thrown. Since the last accessed - // timestamp is not important such exeptions are just silently ignored. - lastAccess = fileInfo.LastAccessTime; - } - catch { } - return new FileEntry(fileInfo.Name, fileInfo.Length, lastAccess, fileInfo.LastWriteTime); - } - - [SupportedOSPlatform("windows")] - public Dictionary GetMetadata(string path, bool isSymlink, bool followSymlink) - { - var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal); - var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path; - var dict = new Dictionary(); - - FileSystemSecurity rules = isDirTarget ? GetAccessControlDir(targetpath) : GetAccessControlFile(targetpath); - var objs = new List(); - foreach (var f in rules.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier))) - objs.Add(new FileSystemAccess((FileSystemAccessRule)f)); - - dict["win-ext:accessrules"] = SerializeObject(objs); - - // Only include the following key when its value is True. - // This prevents unnecessary 'metadata change' detections when upgrading from - // older versions (pre-2.0.5.101) that didn't store this value at all. - // When key is not present, its value is presumed False by the restore code. - if (rules.AreAccessRulesProtected) - { - dict["win-ext:accessrulesprotected"] = "True"; - } - - return dict; - } - - [SupportedOSPlatform("windows")] - public void SetMetadata(string path, Dictionary data, bool restorePermissions) - { - if (restorePermissions) - { - var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal); - var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path; - - FileSystemSecurity rules = isDirTarget ? GetAccessControlDir(targetpath) : GetAccessControlFile(targetpath); - - if (data.ContainsKey("win-ext:accessrulesprotected")) - { - bool isProtected = bool.Parse(data["win-ext:accessrulesprotected"]); - if (rules.AreAccessRulesProtected != isProtected) - rules.SetAccessRuleProtection(isProtected, false); - } - - if (data.ContainsKey("win-ext:accessrules")) - { - var content = DeserializeObject(data["win-ext:accessrules"]); - var c = rules.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)); - for (var i = c.Count - 1; i >= 0; i--) - rules.RemoveAccessRule((FileSystemAccessRule)c[i]); - - Exception ex = null; - - foreach (var r in content) - { - // Attempt to apply as many rules as we can - try - { - rules.AddAccessRule(r.Create(rules)); - } - catch (Exception e) - { - ex = e; - } - } - - if (ex != null) - throw ex; - } - - if (isDirTarget) - SetAccessControlDir(targetpath, (DirectorySecurity)rules); - else - SetAccessControlFile(targetpath, (FileSecurity)rules); - } - } - - public string PathCombine(params string[] paths) - => Path.Combine(paths); - - public void CreateSymlink(string symlinkfile, string target, bool asDir) - { - if (FileExists(symlinkfile) || DirectoryExists(symlinkfile)) - throw new IOException(string.Format("File already exists: {0}", symlinkfile)); - - - if (asDir) - { - Directory.CreateSymbolicLink(AddExtendedDevicePathPrefix(symlinkfile), target); - } - else - { - File.CreateSymbolicLink(AddExtendedDevicePathPrefix(symlinkfile), target); - } - - //Sadly we do not get a notification if the creation fails :( - FileAttributes attr = 0; - if ((!asDir && FileExists(symlinkfile)) || (asDir && DirectoryExists(symlinkfile))) - try { attr = GetFileAttributes(symlinkfile); } - catch { } - - if ((attr & FileAttributes.ReparsePoint) == 0) - throw new IOException(string.Format("Unable to create symlink, check account permissions: {0}", symlinkfile)); - } - - /// - /// Sets the unix permission user read-write Only. - /// - /// The file to set permissions on. - public void FileSetPermissionUserRWOnly(string path) - { - // Create directory security settings - var security = new FileSecurity(); - - // Remove inherited permissions to ensure only the current user has access - security.SetAccessRuleProtection(true, false); - - // Grant the current user read access - security.AddAccessRule(new FileSystemAccessRule( - CURRENT_USERNAME, - FileSystemRights.FullControl, - AccessControlType.Allow - )); - - security.AddAccessRule(new FileSystemAccessRule( - LOCAL_SYSTEM_NAME, - FileSystemRights.FullControl, - AccessControlType.Allow - )); - - // Adjust with the new security settings - new FileInfo(path).SetAccessControl(security); - } - - /// - /// Sets the permission to read-write only for the current user. - /// - /// The directory to set permissions on. - public void DirectorySetPermissionUserRWOnly(string path) - { - // Create directory security settings - var security = new DirectorySecurity(); - - // Remove inherited permissions to ensure only the current user has access - security.SetAccessRuleProtection(true, false); - - // Grant the current user read access - security.AddAccessRule(new FileSystemAccessRule( - CURRENT_USERNAME, - FileSystemRights.FullControl, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, // Apply to subfolders & files - PropagationFlags.None, // Keeps inheritance settings intact - AccessControlType.Allow - )); - - security.AddAccessRule(new FileSystemAccessRule( - LOCAL_SYSTEM_NAME, - FileSystemRights.FullControl, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, // Apply to subfolders & files - PropagationFlags.None, // Keeps inheritance settings intact - AccessControlType.Allow - )); - - // Adjust with the new security settings - new DirectoryInfo(path).SetAccessControl(security); - } - #endregion - } -} - +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Security.AccessControl; +using System.IO; +using System.Linq; + +using Duplicati.Library.Interface; +using Newtonsoft.Json; +using System.Runtime.Versioning; +using System.Security.Principal; + +namespace Duplicati.Library.Common.IO +{ + [SupportedOSPlatform("windows")] + public struct SystemIOWindows : ISystemIO + { + // Based on the constant names used in + // https://github.com/dotnet/runtime/blob/v5.0.12/src/libraries/Common/src/System/IO/PathInternal.Windows.cs + private const string ExtendedDevicePathPrefix = @"\\?\"; + private const string UncPathPrefix = @"\\"; + private const string AltUncPathPrefix = @"//"; + private const string UncExtendedPathPrefix = @"\\?\UNC\"; + + private static readonly string DIRSEP = Util.DirectorySeparatorString; + + /// + /// The current user SID + /// + private static readonly SecurityIdentifier CURRENT_USER_SID = WindowsIdentity.GetCurrent().User; + + /// + /// The LocalSystem user SID + /// + private static readonly SecurityIdentifier LOCAL_SYSTEM_SID = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + + /// + /// The Administrator user SID + /// + private static readonly SecurityIdentifier ADMINISTRATORS_SID = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + + /// + /// Prefix path with one of the extended device path prefixes + /// (@"\\?\" or @"\\?\UNC\") but only if it's a fully qualified + /// path with no relative components (i.e., with no "." or ".." + /// as part of the path). + /// + public static string AddExtendedDevicePathPrefix(string path) + { + if (IsPrefixedWithExtendedDevicePathPrefix(path)) + { + // For example: \\?\C:\Temp\foo.txt or \\?\UNC\example.com\share\foo.txt + return path; + } + else + { + var hasRelativePathComponents = HasRelativePathComponents(path); + if (IsPrefixedWithUncPathPrefix(path) && !hasRelativePathComponents) + { + // For example: \\example.com\share\foo.txt or //example.com/share/foo.txt + return UncExtendedPathPrefix + ConvertSlashes(path.Substring(UncPathPrefix.Length)); + } + else if (DotNetRuntimePathWindows.IsPathFullyQualified(path) && !hasRelativePathComponents) + { + // For example: C:\Temp\foo.txt or C:/Temp/foo.txt + return ExtendedDevicePathPrefix + ConvertSlashes(path); + } + else + { + // A relative path or a fully qualified path with relative + // path components so the extended device path prefixes + // cannot be applied. + // + // For example: foo.txt or C:\Temp\..\foo.txt + return path; + } + } + } + + /// + /// Returns true if prefixed with @"\\" or @"//". + /// + private static bool IsPrefixedWithUncPathPrefix(string path) + { + return path.StartsWith(UncPathPrefix, StringComparison.Ordinal) || + path.StartsWith(AltUncPathPrefix, StringComparison.Ordinal); + } + + /// + /// Returns true if prefixed with @"\\?\UNC\" or @"\\?\". + /// + private static bool IsPrefixedWithExtendedDevicePathPrefix(string path) + { + return path.StartsWith(UncExtendedPathPrefix, StringComparison.Ordinal) || + path.StartsWith(ExtendedDevicePathPrefix, StringComparison.Ordinal); + } + + private static string[] relativePathComponents = new[] { ".", ".." }; + + /// + /// Returns true if contains relative path components; i.e., "." or "..". + /// + private static bool HasRelativePathComponents(string path) + { + return GetPathComponents(path).Any(pathComponent => relativePathComponents.Contains(pathComponent)); + } + + /// + /// Returns a sequence representing the files and directories in . + /// + private static IEnumerable GetPathComponents(string path) + { + while (!String.IsNullOrEmpty(path)) + { + var pathComponent = Path.GetFileName(path); + if (!String.IsNullOrEmpty(pathComponent)) + { + yield return pathComponent; + } + path = Path.GetDirectoryName(path); + } + } + + /// + /// Removes either of the extended device path prefixes + /// (@"\\?\" or @"\\?\UNC\") if is prefixed + /// with one of them. + /// + public static string RemoveExtendedDevicePathPrefix(string path) + { + if (path.StartsWith(UncExtendedPathPrefix, StringComparison.Ordinal)) + { + // @"\\?\UNC\example.com\share\file.txt" to @"\\example.com\share\file.txt" + return UncPathPrefix + path.Substring(UncExtendedPathPrefix.Length); + } + else if (path.StartsWith(ExtendedDevicePathPrefix, StringComparison.Ordinal)) + { + // @"\\?\C:\file.txt" to @"C:\file.txt" + return path.Substring(ExtendedDevicePathPrefix.Length); + } + else + { + return path; + } + } + + /// + /// Convert forward slashes to backslashes. + /// + /// Path with forward slashes replaced by backslashes. + private static string ConvertSlashes(string path) + { + return path.Replace("/", Util.DirectorySeparatorString); + } + + + [SupportedOSPlatform("windows")] + private class FileSystemAccess + { + // Use JsonProperty Attribute to allow readonly fields to be set by deserializer + // https://github.com/duplicati/duplicati/issues/4028 + [JsonProperty] + public readonly FileSystemRights Rights; + [JsonProperty] + public readonly AccessControlType ControlType; + [JsonProperty] + public readonly string SID; + [JsonProperty] + public readonly bool Inherited; + [JsonProperty] + public readonly InheritanceFlags Inheritance; + [JsonProperty] + public readonly PropagationFlags Propagation; + + public FileSystemAccess() + { + } + + public FileSystemAccess(FileSystemAccessRule rule) + { + Rights = rule.FileSystemRights; + ControlType = rule.AccessControlType; + SID = rule.IdentityReference.Value; + Inherited = rule.IsInherited; + Inheritance = rule.InheritanceFlags; + Propagation = rule.PropagationFlags; + } + + public FileSystemAccessRule Create(FileSystemSecurity owner) + { + return (FileSystemAccessRule)owner.AccessRuleFactory( + new System.Security.Principal.SecurityIdentifier(SID), + (int)Rights, + Inherited, + Inheritance, + Propagation, + ControlType); + } + } + + private static JsonSerializer _cachedSerializer; + + private JsonSerializer Serializer + { + get + { + if (_cachedSerializer != null) + { + return _cachedSerializer; + } + + _cachedSerializer = JsonSerializer.Create( + new JsonSerializerSettings { Culture = System.Globalization.CultureInfo.InvariantCulture }); + + return _cachedSerializer; + } + } + + private string SerializeObject(T o) + { + using (var tw = new StringWriter()) + { + Serializer.Serialize(tw, o); + tw.Flush(); + return tw.ToString(); + } + } + + private T DeserializeObject(string data) + { + using (var tr = new StringReader(data)) + return (T)Serializer.Deserialize(tr, typeof(T)); + } + + + [SupportedOSPlatform("windows")] + private FileSystemSecurity GetAccessControlDir(string path) + => new DirectoryInfo(AddExtendedDevicePathPrefix(path)).GetAccessControl(); + + + [SupportedOSPlatform("windows")] + private FileSystemSecurity GetAccessControlFile(string path) + => new FileInfo(AddExtendedDevicePathPrefix(path)).GetAccessControl(); + + + [SupportedOSPlatform("windows")] + private void SetAccessControlFile(string path, FileSecurity rules) + => new FileInfo(AddExtendedDevicePathPrefix(path)).SetAccessControl(rules); + + [SupportedOSPlatform("windows")] + private void SetAccessControlDir(string path, DirectorySecurity rules) + => new DirectoryInfo(AddExtendedDevicePathPrefix(path)).SetAccessControl(rules); + + #region ISystemIO implementation + public void DirectoryCreate(string path) + => Directory.CreateDirectory(AddExtendedDevicePathPrefix(path)); + + public void DirectoryDelete(string path, bool recursive) + => Directory.Delete(AddExtendedDevicePathPrefix(path), recursive); + + public bool DirectoryExists(string path) + => Directory.Exists(AddExtendedDevicePathPrefix(path)); + + public void DirectoryMove(string sourceDirName, string destDirName) + => Directory.Move(AddExtendedDevicePathPrefix(sourceDirName), AddExtendedDevicePathPrefix(destDirName)); + + public void FileDelete(string path) + => File.Delete(AddExtendedDevicePathPrefix(path)); + + public void FileSetLastWriteTimeUtc(string path, DateTime time) + => File.SetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path), time); + + public void FileSetCreationTimeUtc(string path, DateTime time) + => File.SetCreationTimeUtc(AddExtendedDevicePathPrefix(path), time); + + public DateTime FileGetLastWriteTimeUtc(string path) + => File.GetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path)); + + public DateTime FileGetCreationTimeUtc(string path) + => File.GetCreationTimeUtc(AddExtendedDevicePathPrefix(path)); + + public bool FileExists(string path) + => File.Exists(AddExtendedDevicePathPrefix(path)); + + public FileStream FileOpenRead(string path) + => File.Open(AddExtendedDevicePathPrefix(path), FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + public FileStream FileOpenReadWrite(string path) + => !FileExists(path) + ? FileCreate(path) + : File.Open(AddExtendedDevicePathPrefix(path), FileMode.Open, FileAccess.ReadWrite); + + public FileStream FileOpenWrite(string path) + => !FileExists(path) + ? FileCreate(path) + : File.OpenWrite(AddExtendedDevicePathPrefix(path)); + + public FileStream FileCreate(string path) + => File.Create(AddExtendedDevicePathPrefix(path)); + + public FileAttributes GetFileAttributes(string path) + => File.GetAttributes(AddExtendedDevicePathPrefix(path)); + + public void SetFileAttributes(string path, FileAttributes attributes) + { + File.SetAttributes(AddExtendedDevicePathPrefix(path), attributes); + } + + /// + /// Returns the symlink target if the entry is a symlink, and null otherwise + /// + /// The file or folder to examine + /// The symlink target + public string GetSymlinkTarget(string file) + => new FileInfo(AddExtendedDevicePathPrefix(file)).LinkTarget; + + public IEnumerable EnumerateFileSystemEntries(string path) + => Directory.EnumerateFileSystemEntries(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix); + + public IEnumerable EnumerateFiles(string path) + => Directory.EnumerateFiles(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix); + + public IEnumerable EnumerateFiles(string path, string searchPattern, SearchOption searchOption) + => Directory.EnumerateFiles(AddExtendedDevicePathPrefix(path), searchPattern, searchOption).Select(RemoveExtendedDevicePathPrefix); + + public string PathGetFileName(string path) + => RemoveExtendedDevicePathPrefix(Path.GetFileName(AddExtendedDevicePathPrefix(path))); + + public string PathGetDirectoryName(string path) + => RemoveExtendedDevicePathPrefix(Path.GetDirectoryName(AddExtendedDevicePathPrefix(path))); + + public string PathGetExtension(string path) + => RemoveExtendedDevicePathPrefix(Path.GetExtension(AddExtendedDevicePathPrefix(path))); + + public string PathChangeExtension(string path, string extension) + => RemoveExtendedDevicePathPrefix(Path.ChangeExtension(AddExtendedDevicePathPrefix(path), extension)); + + public void DirectorySetLastWriteTimeUtc(string path, DateTime time) + { + Directory.SetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path), time); + } + + public void DirectorySetCreationTimeUtc(string path, DateTime time) + { + Directory.SetCreationTimeUtc(AddExtendedDevicePathPrefix(path), time); + } + + public void FileMove(string source, string target) + { + File.Move(AddExtendedDevicePathPrefix(source), AddExtendedDevicePathPrefix(target)); + } + + public long FileLength(string path) + => new FileInfo(AddExtendedDevicePathPrefix(path)).Length; + + public string GetPathRoot(string path) + => IsPrefixedWithExtendedDevicePathPrefix(path) + ? Path.GetPathRoot(path) + : RemoveExtendedDevicePathPrefix(Path.GetPathRoot(AddExtendedDevicePathPrefix(path))); + public string[] GetDirectories(string path) + => IsPrefixedWithExtendedDevicePathPrefix(path) + ? Directory.GetDirectories(path) + : Directory.GetDirectories(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix).ToArray(); + + public string[] GetFiles(string path) + => IsPrefixedWithExtendedDevicePathPrefix(path) + ? Directory.GetFiles(path) + : Directory.GetFiles(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix).ToArray(); + + public string[] GetFiles(string path, string searchPattern) + => IsPrefixedWithExtendedDevicePathPrefix(path) + ? Directory.GetFiles(path, searchPattern) + : Directory.GetFiles(AddExtendedDevicePathPrefix(path), searchPattern).Select(RemoveExtendedDevicePathPrefix).ToArray(); + + public DateTime GetCreationTimeUtc(string path) + => Directory.GetCreationTimeUtc(AddExtendedDevicePathPrefix(path)); + + public DateTime GetLastWriteTimeUtc(string path) + => Directory.GetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path)); + + public IEnumerable EnumerateDirectories(string path) + => IsPrefixedWithExtendedDevicePathPrefix(path) + ? Directory.EnumerateDirectories(path) + : Directory.EnumerateDirectories(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix); + + public IEnumerable EnumerateFileEntries(string path) + { + // For consistency with previous implementation, enumerate files first and directories after + var dir = IsPrefixedWithExtendedDevicePathPrefix(path) + ? new DirectoryInfo(path) + : new DirectoryInfo(AddExtendedDevicePathPrefix(path)); + + foreach (FileInfo file in dir.EnumerateFiles()) + yield return FileEntry(file); + + foreach (DirectoryInfo d in dir.EnumerateDirectories()) + yield return DirectoryEntry(d); + } + + public void FileCopy(string source, string target, bool overwrite) + => File.Copy(AddExtendedDevicePathPrefix(source), AddExtendedDevicePathPrefix(target), overwrite); + + public string PathGetFullPath(string path) + { + // Desired behavior: + // 1. If path is already prefixed with \\?\, it should be left untouched + // 2. If path is not already prefixed with \\?\, the return value should also not be prefixed + // 3. If path is relative or has relative components, that should be resolved by calling Path.GetFullPath() + // 4. If path is not relative and has no relative components, prefix with \\?\ to prevent normalization from munging "problematic Windows paths" + return IsPrefixedWithExtendedDevicePathPrefix(path) + ? path + : RemoveExtendedDevicePathPrefix(Path.GetFullPath(AddExtendedDevicePathPrefix(path))); + } + + public IFileEntry DirectoryEntry(string path) + => DirectoryEntry(new DirectoryInfo(AddExtendedDevicePathPrefix(path))); + + public IFileEntry DirectoryEntry(DirectoryInfo dInfo) + => new FileEntry(dInfo.Name, 0, dInfo.LastAccessTime, dInfo.LastWriteTime) + { + IsFolder = true + }; + + public IFileEntry FileEntry(string path) + => FileEntry(new FileInfo(AddExtendedDevicePathPrefix(path))); + + public IFileEntry FileEntry(FileInfo fileInfo) + { + var lastAccess = new DateTime(); + try + { + // Internally this will convert the FILETIME value from Windows API to a + // DateTime. If the value represents a date after 12/31/9999 it will throw + // ArgumentOutOfRangeException, because this is not supported by DateTime. + // Some file systems seem to set strange access timestamps on files, which + // may lead to this exception being thrown. Since the last accessed + // timestamp is not important such exeptions are just silently ignored. + lastAccess = fileInfo.LastAccessTime; + } + catch { } + return new FileEntry(fileInfo.Name, fileInfo.Length, lastAccess, fileInfo.LastWriteTime); + } + + [SupportedOSPlatform("windows")] + public Dictionary GetMetadata(string path, bool isSymlink, bool followSymlink) + { + var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal); + var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path; + var dict = new Dictionary(); + + FileSystemSecurity rules = isDirTarget ? GetAccessControlDir(targetpath) : GetAccessControlFile(targetpath); + var objs = new List(); + foreach (var f in rules.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier))) + objs.Add(new FileSystemAccess((FileSystemAccessRule)f)); + + dict["win-ext:accessrules"] = SerializeObject(objs); + + // Only include the following key when its value is True. + // This prevents unnecessary 'metadata change' detections when upgrading from + // older versions (pre-2.0.5.101) that didn't store this value at all. + // When key is not present, its value is presumed False by the restore code. + if (rules.AreAccessRulesProtected) + { + dict["win-ext:accessrulesprotected"] = "True"; + } + + return dict; + } + + [SupportedOSPlatform("windows")] + public void SetMetadata(string path, Dictionary data, bool restorePermissions) + { + if (restorePermissions) + { + var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal); + var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path; + + FileSystemSecurity rules = isDirTarget ? GetAccessControlDir(targetpath) : GetAccessControlFile(targetpath); + + if (data.ContainsKey("win-ext:accessrulesprotected")) + { + bool isProtected = bool.Parse(data["win-ext:accessrulesprotected"]); + if (rules.AreAccessRulesProtected != isProtected) + rules.SetAccessRuleProtection(isProtected, false); + } + + if (data.ContainsKey("win-ext:accessrules")) + { + var content = DeserializeObject(data["win-ext:accessrules"]); + var c = rules.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)); + for (var i = c.Count - 1; i >= 0; i--) + rules.RemoveAccessRule((FileSystemAccessRule)c[i]); + + Exception ex = null; + + foreach (var r in content) + { + // Attempt to apply as many rules as we can + try + { + rules.AddAccessRule(r.Create(rules)); + } + catch (Exception e) + { + ex = e; + } + } + + if (ex != null) + throw ex; + } + + if (isDirTarget) + SetAccessControlDir(targetpath, (DirectorySecurity)rules); + else + SetAccessControlFile(targetpath, (FileSecurity)rules); + } + } + + public string PathCombine(params string[] paths) + => Path.Combine(paths); + + public void CreateSymlink(string symlinkfile, string target, bool asDir) + { + if (FileExists(symlinkfile) || DirectoryExists(symlinkfile)) + throw new IOException(string.Format("File already exists: {0}", symlinkfile)); + + + if (asDir) + { + Directory.CreateSymbolicLink(AddExtendedDevicePathPrefix(symlinkfile), target); + } + else + { + File.CreateSymbolicLink(AddExtendedDevicePathPrefix(symlinkfile), target); + } + + //Sadly we do not get a notification if the creation fails :( + FileAttributes attr = 0; + if ((!asDir && FileExists(symlinkfile)) || (asDir && DirectoryExists(symlinkfile))) + try { attr = GetFileAttributes(symlinkfile); } + catch { } + + if ((attr & FileAttributes.ReparsePoint) == 0) + throw new IOException(string.Format("Unable to create symlink, check account permissions: {0}", symlinkfile)); + } + + /// + /// Sets the file permissions to user read-write only. + /// + /// The file to set permissions on. + public void FileSetPermissionUserRWOnly(string path) + { + // Create directory security settings + var security = new FileSecurity(); + + // Remove inherited permissions to ensure only the current user has access + security.SetAccessRuleProtection(true, false); + + var users = new[] { + CURRENT_USER_SID, + LOCAL_SYSTEM_SID, + ADMINISTRATORS_SID + }.ToHashSet(); + + // Grant the users read access + foreach (var user in users) + security.AddAccessRule(new FileSystemAccessRule( + user, + FileSystemRights.FullControl, + AccessControlType.Allow + )); + + // Adjust with the new security settings + new FileInfo(path).SetAccessControl(security); + } + + /// + /// Sets the directory permissions to read-write only for the current user. + /// + /// The directory to set permissions on. + public void DirectorySetPermissionUserRWOnly(string path) + { + // Create directory security settings + var security = new DirectorySecurity(); + + // Remove inherited permissions to ensure only the current user has access + security.SetAccessRuleProtection(true, false); + + var users = new[] { + CURRENT_USER_SID, + LOCAL_SYSTEM_SID, + ADMINISTRATORS_SID + }.ToHashSet(); + + // Grant the users full access + foreach (var user in users) + security.AddAccessRule(new FileSystemAccessRule( + user, + FileSystemRights.FullControl, // Using full-control to allow changing permissions as well + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, // Apply to subfolders & files + PropagationFlags.None, // Keeps inheritance settings intact + AccessControlType.Allow + )); + + // Adjust with the new security settings + new DirectoryInfo(path).SetAccessControl(security); + } + #endregion + } +} +