diff --git a/Duplicati/Library/Backend/File/FileBackend.cs b/Duplicati/Library/Backend/File/FileBackend.cs index 537d563a1..7f2434fd1 100644 --- a/Duplicati/Library/Backend/File/FileBackend.cs +++ b/Duplicati/Library/Backend/File/FileBackend.cs @@ -24,8 +24,6 @@ using Duplicati.Library.Interface; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Runtime.Versioning; namespace Duplicati.Library.Backend { @@ -451,72 +449,12 @@ namespace Duplicati.Library.Backend throw new FileMissingException(Strings.FileBackend.FileNotFoundError(ex.Message), ex); } - /// - /// Gets the drive or volume information for the specified path. - /// - /// The drive or volume information, or null if it could not be determined. - private DriveInfo? GetDrive() - { - string root; - if (!OperatingSystem.IsWindows()) - { - string path = Util.AppendDirSeparator(systemIO.PathGetFullPath(m_path)); - - // If the built-in .NET DriveInfo works, use it - try { return new DriveInfo(path); } - catch { } - - root = "/"; - - //Find longest common prefix from mounted devices - //TODO: Can trick this with symlinks, where the symlink is on one mounted volume, - // and the actual storage is on another - foreach (var di in DriveInfo.GetDrives()) - if (path.StartsWith(Util.AppendDirSeparator(di.Name), StringComparison.Ordinal) && di.Name.Length > root.Length) - root = di.Name; - } - else - { - root = systemIO.GetPathRoot(m_path); - } - - // On Windows, DriveInfo is only valid for lettered drives. (e.g., not for UNC paths and shares) - // So only attempt to get it if we aren't on Windows or if the root starts with a letter. - if (!OperatingSystem.IsWindows() || (root.Length > 0 && char.IsLetter(root[0]))) - { - try - { - return new DriveInfo(root); - } - catch (ArgumentException) - { - // If there was a problem, fall back to returning null - } - } - - return null; - } - /// public Task GetQuotaInfoAsync(CancellationToken cancelToken) { - var driveInfo = this.GetDrive(); - if (driveInfo != null) - { - // Check that the total space is above 0, because Mono sometimes reports 0 for unknown file systems - // If the drive actually has a total size of 0, this should be obvious immediately due to write errors - if (driveInfo.TotalSize > 0) - { - return Task.FromResult(new QuotaInfo(driveInfo.TotalSize, driveInfo.AvailableFreeSpace)); - } - } - - if (OperatingSystem.IsWindows()) - { - // If we can't get the DriveInfo on Windows, fallback to GetFreeDiskSpaceEx - // https://stackoverflow.com/questions/2050343/programmatically-determining-space-available-from-unc-path - return Task.FromResult(GetDiskFreeSpace(m_path)); - } + var spaceInfo = Utility.Utility.GetFreeSpaceForPath(m_path); + if (spaceInfo != null) + return Task.FromResult(new QuotaInfo(spaceInfo.Value.TotalSpace, spaceInfo.Value.FreeSpace)); return Task.FromResult(null); } @@ -536,50 +474,6 @@ namespace Duplicati.Library.Backend return Task.CompletedTask; } - /// - /// Get the disk free space using the Win32 API's GetDiskFreeSpaceEx function. - /// - /// Directory - /// Quota info - [MethodImpl(MethodImplOptions.NoInlining)] - [SupportedOSPlatform("windows")] - public static QuotaInfo? GetDiskFreeSpace(string directory) - { - ulong available; - ulong total; - if (WindowsDriveHelper.GetDiskFreeSpaceEx(directory, out available, out total, out _)) - { - return new QuotaInfo((long)total, (long)available); - } - else - { - return null; - } - } - - /// - /// Provides helper methods for working with Windows drives. - /// - [SupportedOSPlatform("windows")] - private static class WindowsDriveHelper - { - /// - /// Gets the free disk space for the specified directory using the Win32 API. - /// - /// The directory name to check - /// The available free bytes - /// The total number of bytes - /// The total number of free bytes - /// - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetDiskFreeSpaceEx( - string lpDirectoryName, - out ulong lpFreeBytesAvailable, - out ulong lpTotalNumberOfBytes, - out ulong lpTotalNumberOfFreeBytes); - } - /// /// Verifies that the target file size matches the source file size. /// diff --git a/Duplicati/Library/Main/Operation/RestoreHandler.cs b/Duplicati/Library/Main/Operation/RestoreHandler.cs index 23d50dabc..2a03260ea 100644 --- a/Duplicati/Library/Main/Operation/RestoreHandler.cs +++ b/Duplicati/Library/Main/Operation/RestoreHandler.cs @@ -51,6 +51,42 @@ namespace Duplicati.Library.Main.Operation m_result = result; } + /// + /// Checks if the tempdir has enough free space relative to the volume size. + /// Logs a warning if free space is less than 4 times the volume size. + /// + private void CheckTempDirFreeSpace() + { + try + { + // Get the tempdir path + var tempDir = TempFolder.SystemTempPath; + + // Get free space in tempdir using the Utility method + var spaceInfo = Library.Utility.Utility.GetFreeSpaceForPath(tempDir); + if (spaceInfo == null) + { + // Could not determine free space, skip the check + return; + } + + // Get the volume size from options + var volumeSize = m_options.VolumeSize; + + // Check if free space is less than 4 times the volume size + if (spaceInfo.Value.FreeSpace < volumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER) + { + Logging.Log.WriteWarningMessage(LOGTAG, "TempDirLowSpace", null, + $"The temporary folder '{tempDir}' has limited free space ({Library.Utility.Utility.FormatSizeString(spaceInfo.Value.FreeSpace)}). " + + $"It is recommended to have at least {Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER} times the volume size ({Library.Utility.Utility.FormatSizeString(volumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER)}) available for optimal restore operation."); + } + } + catch + { + // Ignore errors during free space check + } + } + /// /// Gets the compression module by parsing the filename /// @@ -106,6 +142,9 @@ namespace Duplicati.Library.Main.Operation { m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Begin); + // Check tempdir free space before starting restore + CheckTempDirFreeSpace(); + // Set the restore path in the results for logging purposes var restorePath = restoreDestination.TargetDestination; m_result.RestorePath = string.IsNullOrEmpty(restorePath) diff --git a/Duplicati/Library/Utility/Utility.cs b/Duplicati/Library/Utility/Utility.cs index 1a4802f77..0f87a2865 100644 --- a/Duplicati/Library/Utility/Utility.cs +++ b/Duplicati/Library/Utility/Utility.cs @@ -65,6 +65,11 @@ namespace Duplicati.Library.Utility /// public const FileAttributes ATTRIBUTE_ERROR = (FileAttributes)(1 << 30); + /// + /// The multiplier used to determine if there is enough free space on the temporary volume + /// + public const long VOLUME_SIZE_FREE_SPACE_MULTIPLIER = 10; + /// /// The callback delegate type used to collecting file information /// @@ -2064,5 +2069,91 @@ namespace Duplicati.Library.Utility return value; } + /// + /// Gets the free and total space available for the specified path. + /// Uses the same approach as FileBackend.GetQuotaInfoAsync. + /// + /// The path to check + /// A tuple with (freeSpace, totalSpace) in bytes, or null if it could not be determined + public static (long FreeSpace, long TotalSpace)? GetFreeSpaceForPath(string path) + { + try + { + // Get the drive info for the path + var root = Path.GetPathRoot(path); + if (string.IsNullOrEmpty(root)) + return null; + + // On Windows, DriveInfo is only valid for lettered drives + if (OperatingSystem.IsWindows() && root.Length > 0 && char.IsLetter(root[0])) + { + try + { + var driveInfo = new DriveInfo(root); + if (driveInfo.TotalSize > 0) + return (driveInfo.AvailableFreeSpace, driveInfo.TotalSize); + } + catch (ArgumentException) + { + // Fall through to Win32 API fallback + } + + // Fallback to Win32 API for UNC paths and other cases + return GetDiskFreeSpaceWin32(path); + } + else if (!OperatingSystem.IsWindows()) + { + var driveInfo = new DriveInfo(root); + if (driveInfo.TotalSize > 0) + return (driveInfo.AvailableFreeSpace, driveInfo.TotalSize); + } + else + { + // Windows but not a lettered drive (e.g., UNC path) + return GetDiskFreeSpaceWin32(path); + } + } + catch + { + // Ignore errors and return null + } + + return null; + } + + /// + /// Gets the free and total disk space using the Win32 API's GetDiskFreeSpaceEx function. + /// + /// Directory + /// A tuple with (freeSpace, totalSpace) in bytes, or null if it could not be determined + [MethodImpl(MethodImplOptions.NoInlining)] + [SupportedOSPlatform("windows")] + private static (long FreeSpace, long TotalSpace)? GetDiskFreeSpaceWin32(string directory) + { + if (!OperatingSystem.IsWindows()) + return null; + + try + { + if (GetDiskFreeSpaceEx(directory, out var available, out var total, out _)) + return ((long)available, (long)total); + } + catch + { + // Ignore errors + } + + return null; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + [SupportedOSPlatform("windows")] + private static extern bool GetDiskFreeSpaceEx( + string lpDirectoryName, + out ulong lpFreeBytesAvailable, + out ulong lpTotalNumberOfBytes, + out ulong lpTotalNumberOfFreeBytes); + } } diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 7b520f1c2..c577e62b9 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -639,6 +639,9 @@ namespace Duplicati.Server connection.DismissNotification(n.ID); } + // Check tempdir free space against volume sizes + CheckTempDirFreeSpace(connection, commandlineOptions); + // Emit warnings if the application has been updated if (connection.ApplicationSettings.LastConfigIssueCheckVersion != UpdaterManager.SelfVersion.Version) { @@ -688,6 +691,132 @@ namespace Duplicati.Server } } + /// + /// Checks if the tempdir has enough free space relative to the volume sizes of configured backups. + /// Emits a warning if free space is less than 4 times the volume size. + /// + /// The database connection + /// The commandline options + private static void CheckTempDirFreeSpace(Connection connection, Dictionary commandlineOptions) + { + const string TEMPDIR_WARNING_ACTION = "config:issue:tempdir-low-space"; + + // Get the tempdir path - check Connection settings first, then fall back to system default + var tempDir = TempFolder.SystemTempPath; + var serverSettings = connection.GetSettings(Connection.ANY_BACKUP_ID); + var tempDirSetting = serverSettings?.FirstOrDefault(s => + string.Equals(s.Name, "tempdir", StringComparison.OrdinalIgnoreCase) || string.Equals(s.Name, "--tempdir", StringComparison.OrdinalIgnoreCase)); + if (tempDirSetting != null && !string.IsNullOrEmpty(tempDirSetting.Value)) + tempDir = tempDirSetting.Value; + + // Get free space in tempdir using the Utility method + var spaceInfo = Library.Utility.Utility.GetFreeSpaceForPath(tempDir); + // Could not determine free space, skip the check + if (spaceInfo == null) + return; + + // Get the maximum volume size from all configured backups + var maxVolumeSize = GetMaxVolumeSize(connection, commandlineOptions); + + // Check if free space is less than 4 times the volume size + if (spaceInfo.Value.FreeSpace < maxVolumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER) + { + // Emit warning + connection.RegisterNotification( + Serialization.NotificationType.Warning, + "Low temporary folder space", + $"The temporary folder '{tempDir}' has limited free space ({Library.Utility.Utility.FormatSizeString(spaceInfo.Value.FreeSpace)}). " + + $"It is recommended to have at least {Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER} times the volume size ({Library.Utility.Utility.FormatSizeString(maxVolumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER)}) available for optimal operation.", + null, + null, + TEMPDIR_WARNING_ACTION, + null, + "TempDirLowSpace", + null, + (self, all) => + { + return all.FirstOrDefault(x => x.Action == TEMPDIR_WARNING_ACTION) ?? self; + } + ); + } + else + { + // Clear any existing warning + var existingNotifications = connection.GetNotifications().Where(x => x.Action == TEMPDIR_WARNING_ACTION).ToList(); + foreach (var n in existingNotifications) + connection.DismissNotification(n.ID); + } + } + + /// + /// Gets the maximum volume size from all configured backups. + /// + /// The database connection + /// The commandline options + /// The maximum volume size in bytes + private static long GetMaxVolumeSize(Connection connection, Dictionary commandlineOptions) + { + const string DEFAULT_VOLUME_SIZE = "50mb"; + long maxVolumeSize = 0; + + // Check if there's a global dblock-size in commandline options + if (commandlineOptions.TryGetValue("dblock-size", out var globalDblockSize) && !string.IsNullOrEmpty(globalDblockSize)) + { + try + { + maxVolumeSize = Sizeparser.ParseSize(globalDblockSize, "mb"); + } + catch + { + // Ignore parsing errors + } + } + + // Check if there's a global dblock-size in the database settings + var serverSettings = connection.GetSettings(Connection.ANY_BACKUP_ID); + var defaultDblockSize = serverSettings?.FirstOrDefault(s => string.Equals(s.Name, "dblock-size", StringComparison.OrdinalIgnoreCase) || string.Equals(s.Name, "--dblock-size", StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(defaultDblockSize?.Value)) + try + { + maxVolumeSize = Math.Max(Sizeparser.ParseSize(defaultDblockSize.Value, "mb"), maxVolumeSize); + } + catch + { + // Ignore parsing errors + } + + // If no global setting, use default + maxVolumeSize = Math.Max(maxVolumeSize, Sizeparser.ParseSize(DEFAULT_VOLUME_SIZE, "mb")); + + // Check all configured backups for their volume sizes + foreach (var backup in connection.Backups) + { + try + { + // Get settings for this backup + var backupId = long.Parse(backup.ID); + var settings = connection.GetSettings(backupId); + + // Look for dblock-size setting + var dblockSizeSetting = settings?.FirstOrDefault(s => + string.Equals(s.Name, "dblock-size", StringComparison.OrdinalIgnoreCase) || string.Equals(s.Name, "--dblock-size", StringComparison.OrdinalIgnoreCase)); + + if (dblockSizeSetting != null && !string.IsNullOrEmpty(dblockSizeSetting.Value)) + { + var backupVolumeSize = Sizeparser.ParseSize(dblockSizeSetting.Value, "mb"); + if (backupVolumeSize > maxVolumeSize) + maxVolumeSize = backupVolumeSize; + } + } + catch + { + // Ignore errors for individual backups + } + } + + return maxVolumeSize; + } + /// /// Creates the application instance to ensure a single instance is running for the current user ///