From d00f3abbf0035b599d9350b0b41963396844cf86 Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Thu, 24 Sep 2020 11:53:20 +0200 Subject: [PATCH] Fixed: Dataloss when moving series folder to root folder with only different casing Fixes #5190 Fixes #5184 --- .../DiskTests/DiskTransferServiceFixture.cs | 129 ++++++++++++++++++ src/NzbDrone.Common/Disk/DiskProviderBase.cs | 11 -- .../Disk/DiskTransferService.cs | 63 ++++++++- .../UpdateEngine/InstallUpdateService.cs | 6 + 4 files changed, 195 insertions(+), 14 deletions(-) diff --git a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs index 1672fe0d4..8e96d5aa6 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs @@ -402,6 +402,58 @@ public void CopyFolder_should_overwrite_existing_folder() VerifyCopyFolder(source.FullName, destination.FullName); } + [Test] + public void CopyFolder_should_detect_caseinsensitive_parents() + { + WindowsOnly(); + + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var root = new DirectoryInfo(GetTempFilePath()); + var source = new DirectoryInfo(root.FullName + "A/series"); + var destination = new DirectoryInfo(root.FullName + "a/series"); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + Assert.Throws(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Copy)); + } + + [Test] + public void CopyFolder_should_detect_caseinsensitive_folder() + { + WindowsOnly(); + + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var root = new DirectoryInfo(GetTempFilePath()); + var source = new DirectoryInfo(root.FullName + "A/series"); + var destination = new DirectoryInfo(root.FullName + "A/Series"); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + Assert.Throws(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Copy)); + } + + [Test] + public void CopyFolder_should_not_copy_casesensitive_folder() + { + MonoOnly(); + + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var root = new DirectoryInfo(GetTempFilePath()); + var source = new DirectoryInfo(root.FullName + "A/series"); + var destination = new DirectoryInfo(root.FullName + "A/Series"); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + // Note: Although technically possible top copy to different case, we're not allowing it + Assert.Throws(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Copy)); + } + [Test] public void CopyFolder_should_ignore_nfs_temp_file() { @@ -451,6 +503,62 @@ public void MoveFolder_should_overwrite_existing_folder() VerifyMoveFolder(original.FullName, source.FullName, destination.FullName); } + [Test] + public void MoveFolder_should_detect_caseinsensitive_parents() + { + WindowsOnly(); + + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var root = new DirectoryInfo(GetTempFilePath()); + var source = new DirectoryInfo(root.FullName + "A/series"); + var destination = new DirectoryInfo(root.FullName + "a/series"); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + Assert.Throws(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Move)); + } + + [Test] + public void MoveFolder_should_rename_caseinsensitive_folder() + { + WindowsOnly(); + + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var root = new DirectoryInfo(GetTempFilePath()); + var source = new DirectoryInfo(root.FullName + "A/series"); + var destination = new DirectoryInfo(root.FullName + "A/Series"); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Move); + + source.FullName.GetActualCasing().Should().Be(destination.FullName); + } + + [Test] + public void MoveFolder_should_rename_casesensitive_folder() + { + MonoOnly(); + + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var root = new DirectoryInfo(GetTempFilePath()); + var source = new DirectoryInfo(root.FullName + "A/series"); + var destination = new DirectoryInfo(root.FullName + "A/Series"); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Move); + + Directory.Exists(source.FullName).Should().Be(false); + Directory.Exists(destination.FullName).Should().Be(true); + } + [Test] public void should_throw_if_destination_is_readonly() { @@ -553,6 +661,23 @@ public void MirrorFolder_should_not_touch_equivalent_files() VerifyCopyFolder(original.FullName, destination.FullName); } + [Test] + public void MirrorFolder_should_handle_trailing_slash() + { + WithRealDiskProvider(); + + var original = GetFilledTempFolder(); + var source = new DirectoryInfo(GetTempFilePath()); + var destination = new DirectoryInfo(GetTempFilePath()); + + Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy); + + var count = Subject.MirrorFolder(source.FullName + Path.DirectorySeparatorChar, destination.FullName); + + count.Should().Equals(3); + VerifyCopyFolder(original.FullName, destination.FullName); + } + [Test] public void TransferFolder_should_use_movefolder_if_on_same_mount() { @@ -752,6 +877,10 @@ private void WithRealDiskProvider() .Setup(v => v.CreateFolder(It.IsAny())) .Callback(v => Directory.CreateDirectory(v)); + Mocker.GetMock() + .Setup(v => v.MoveFolder(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((v, r, b) => Directory.Move(v, r)); + Mocker.GetMock() .Setup(v => v.DeleteFolder(It.IsAny(), It.IsAny())) .Callback((v, r) => Directory.Delete(v, r)); diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 32b046b58..60cf6a623 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -258,17 +258,6 @@ public void MoveFolder(string source, string destination, bool overwrite = false Ensure.That(source, () => source).IsValidPath(); Ensure.That(destination, () => destination).IsValidPath(); - if (source.PathEquals(destination)) - { - throw new IOException(string.Format("Source and destination can't be the same {0}", source)); - } - - if (FolderExists(destination) && overwrite) - { - DeleteFolder(destination, true); - } - - RemoveReadOnlyFolder(source); Directory.Move(source, destination); } diff --git a/src/NzbDrone.Common/Disk/DiskTransferService.cs b/src/NzbDrone.Common/Disk/DiskTransferService.cs index 5d7e7d23a..44d28a9df 100644 --- a/src/NzbDrone.Common/Disk/DiskTransferService.cs +++ b/src/NzbDrone.Common/Disk/DiskTransferService.cs @@ -4,7 +4,6 @@ using System.Threading; using NLog; using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Disk @@ -27,11 +26,56 @@ public DiskTransferService(IDiskProvider diskProvider, Logger logger) _logger = logger; } + private string ResolveRealParentPath(string path) + { + var parentPath = path.GetParentPath(); + if (!_diskProvider.FolderExists(parentPath)) + { + return path; + } + + var realParentPath = parentPath.GetActualCasing(); + + var partialChildPath = path.Substring(parentPath.Length); + + return realParentPath + partialChildPath; + } + public TransferMode TransferFolder(string sourcePath, string targetPath, TransferMode mode) { Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath(); + sourcePath = ResolveRealParentPath(sourcePath); + targetPath = ResolveRealParentPath(targetPath); + + _logger.Debug("{0} Directory [{1}] > [{2}]", mode, sourcePath, targetPath); + + if (sourcePath == targetPath) + { + throw new IOException(string.Format("Source and destination can't be the same {0}", sourcePath)); + } + + if (mode == TransferMode.Move && sourcePath.PathEquals(targetPath, StringComparison.InvariantCultureIgnoreCase) && _diskProvider.FolderExists(targetPath)) + { + // Move folder out of the way to allow case-insensitive renames + var tempPath = sourcePath + ".backup~"; + _logger.Trace("Rename Intermediate Directory [{0}] > [{1}]", sourcePath, tempPath); + _diskProvider.MoveFolder(sourcePath, tempPath); + + if (!_diskProvider.FolderExists(targetPath)) + { + _logger.Trace("Rename Intermediate Directory [{0}] > [{1}]", tempPath, targetPath); + _logger.Debug("Rename Directory [{0}] > [{1}]", sourcePath, targetPath); + _diskProvider.MoveFolder(tempPath, targetPath); + return mode; + } + + // There were two separate folders, revert the intermediate rename and let the recursion deal with it + _logger.Trace("Rename Intermediate Directory [{0}] > [{1}]", tempPath, sourcePath); + _diskProvider.MoveFolder(tempPath, sourcePath); + } + if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath)) { var sourceMount = _diskProvider.GetMount(sourcePath); @@ -40,7 +84,7 @@ public TransferMode TransferFolder(string sourcePath, string targetPath, Transfe // If we're on the same mount, do a simple folder move. if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory) { - _logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath); + _logger.Debug("Rename Directory [{0}] > [{1}]", sourcePath, targetPath); _diskProvider.MoveFolder(sourcePath, targetPath); return mode; } @@ -79,6 +123,13 @@ public TransferMode TransferFolder(string sourcePath, string targetPath, Transfe if (mode.HasFlag(TransferMode.Move)) { + var totalSize = _diskProvider.GetFileInfos(sourcePath).Sum(v => v.Length); + + if (totalSize > (100 * 1024L * 1024L)) + { + throw new IOException($"Large files still exist in {sourcePath} after folder move, not deleting source folder"); + } + _diskProvider.DeleteFolder(sourcePath, true); } @@ -92,7 +143,10 @@ public int MirrorFolder(string sourcePath, string targetPath) Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath(); - _logger.Debug("Mirror [{0}] > [{1}]", sourcePath, targetPath); + sourcePath = ResolveRealParentPath(sourcePath); + targetPath = ResolveRealParentPath(targetPath); + + _logger.Debug("Mirror Folder [{0}] > [{1}]", sourcePath, targetPath); if (!_diskProvider.FolderExists(targetPath)) { @@ -204,6 +258,9 @@ public TransferMode TransferFile(string sourcePath, string targetPath, TransferM Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath(); + sourcePath = ResolveRealParentPath(sourcePath); + targetPath = ResolveRealParentPath(targetPath); + _logger.Debug("{0} [{1}] > [{2}]", mode, sourcePath, targetPath); var originalSize = _diskProvider.GetFileSize(sourcePath); diff --git a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs index 0de0b4e69..da0ce63d2 100644 --- a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs +++ b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs @@ -90,6 +90,12 @@ public void Start(string installationFolder, int processId) Verify(installationFolder, processId); + if (installationFolder.EndsWith(@"\bin\Radarr") || installationFolder.EndsWith(@"/bin/Radarr")) + { + installationFolder = installationFolder.GetParentPath(); + _logger.Info("Fixed Installation Folder: {0}", installationFolder); + } + var appType = _detectApplicationType.GetAppType(); _processProvider.FindProcessByName(ProcessProvider.RADARR_CONSOLE_PROCESS_NAME);