// 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.IO; using Duplicati.Library.Logging; #nullable enable namespace Duplicati.Library.Common.IO { public static class Util { /// /// The log tag for messages /// private static readonly string LOGTAG = Log.LogTagFromType(typeof(Util)); /// /// A cached instance of the directory separator as a string /// public static readonly string DirectorySeparatorString = Path.DirectorySeparatorChar.ToString(); /// /// A cached instance of the alternate directory separator as a string /// public static readonly string AltDirectorySeparatorString = Path.AltDirectorySeparatorChar.ToString(); /// /// Filename of a marker file that can be put inside the data folder to prevent Duplicati from fixing lax permissions /// public const string InsecurePermissionsMarkerFile = "insecure-permissions.txt"; /// /// Appends the appropriate directory separator to paths, depending on OS. /// Does not append the separator if the path already ends with it. /// /// The path to append to /// The path with the directory separator appended public static string AppendDirSeparator(string path) { return AppendDirSeparator(path, DirectorySeparatorString); } /// /// Appends the specified directory separator to paths. /// Does not append the separator if the path already ends with it. /// /// The path to append to /// The directory separator to use /// The path with the directory separator appended public static string AppendDirSeparator(string path, string separator) { return !path.EndsWith(separator, StringComparison.Ordinal) ? path + separator : path; } /// /// Guesses the directory separator from the path /// /// The path to guess the separator from /// The guessed directory separator public static string GuessDirSeparator(string? path) { return string.IsNullOrWhiteSpace(path) || path.StartsWith("/", StringComparison.Ordinal) ? "/" : "\\"; } /// /// Checks if the path is inside the Windows folder /// /// The path to check /// true if the path is inside the Windows folder, false otherwise public static bool IsPathUnderWindowsFolder(string path) { if (!OperatingSystem.IsWindows()) return false; return path.StartsWith(AppendDirSeparator(Environment.GetFolderPath(Environment.SpecialFolder.Windows)), StringComparison.OrdinalIgnoreCase); } /// /// Verify that the given path is within the target destination. /// /// The path to verify. /// The target destination to verify against. /// true if the path is inside the target destination, false otherwise. public static bool IsPathInsideTarget(string path, string targetDestination) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path)); if (string.IsNullOrWhiteSpace(targetDestination)) throw new ArgumentNullException(nameof(targetDestination)); var fullPath = Path.GetFullPath(path); var fullTarget = Path.GetFullPath(targetDestination); // Resolve the target destination once var realTarget = GetFinalPath(fullTarget); // Resolve the path of the file/folder we are about to create var realPath = GetFinalPath(fullPath); // Normalize both paths to ensure consistent comparison // This handles cases where paths differ only by trailing separators var normalizedTarget = realTarget.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var normalizedPath = realPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // If the paths are equal after normalization, it's valid if (string.Equals(normalizedPath, normalizedTarget, StringComparison.OrdinalIgnoreCase)) return true; var relative = Path.GetRelativePath(realTarget, realPath); if (relative.StartsWith("..") || Path.IsPathRooted(relative)) return false; return true; } /// /// Checks if the path is a Windows drive root (e.g., "C:\" or "D:\"). /// /// The path to check. /// true if the path is a drive root, false otherwise. private static bool IsDriveRoot(string path) { if (!OperatingSystem.IsWindows()) return false; // GetPathRoot returns "C:\" for "C:\" or "C:\Users" var root = Path.GetPathRoot(path); if (string.IsNullOrEmpty(root)) return false; // Check if the input is exactly the root return string.Equals(path.TrimEnd('\\'), root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase) && root.EndsWith(Path.DirectorySeparatorChar); } /// /// Get the final resolved path, accounting for symlinks in existing segments. /// /// The path to resolve. /// The fully resolved path, with all symlinks resolved. private static string GetFinalPath(string path) { if (string.IsNullOrEmpty(path)) return path; var current = Path.GetFullPath(path); var ghostSegments = new Stack(); // 1. Walk up the tree until we find a segment that exists on disk while (!Path.Exists(current)) { var parent = Path.GetDirectoryName(current); // If we've reached the root and nothing exists (rare/impossible for absolute paths), // we have to stop and return the original path. if (string.IsNullOrEmpty(parent) || parent == current) return path; // Store the part that doesn't exist so we can put it back later ghostSegments.Push(Path.GetFileName(current)); current = parent; } // 2. Resolve symlinks for the part of the path that actually exists // Skip symlink resolution for Windows drive roots (e.g., "C:\") as // ResolveLinkTarget can throw DirectoryNotFoundException on some systems var resolvedPath = current; if (!IsDriveRoot(current)) { FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); try { resolvedPath = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.FullName; } catch (Exception ex) { Log.WriteWarningMessage(LOGTAG, "ResolveLinkTargetFailed", ex, "Failed to resolve link target for {0}, assuming it is not a link", current); } } // 3. Re-attach the non-existent segments to the resolved base path while (ghostSegments.Count > 0) resolvedPath = Path.Combine(resolvedPath, ghostSegments.Pop()); return resolvedPath; } } }