// Copyright (C) 2026, 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 Duplicati.Library.Interface; using NUnit.Framework; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using Assert = NUnit.Framework.Legacy.ClassicAssert; namespace Duplicati.UnitTest { public class CompactDisruptionTests : BasicSetupHelper { private string VerificationToString(IEnumerable>>> verifications) { StringBuilder res = new StringBuilder(); foreach (var file in verifications) { if (file.Value.Any()) { res.AppendLine($"{file.Key}: {file.Value.Count()} errors"); foreach (var c in file.Value) { res.AppendLine($"\t{c.Key}: {c.Value}"); } } } return res.ToString(); } /// /// Each backup run needs a unique timestamp to avoid the same backup being created. /// During testing we often need to run backups in quick succession, so we need to /// sleep until the next second to make sure the next backup timestamp is not the same. /// /// The timestamp of the previous backup private void SleepUntilNextSecond(DateTime prevTimestamp) { // Sleep until the next second to make sure the next backup is not the same var nextSecond = prevTimestamp.AddMilliseconds(-prevTimestamp.Millisecond).AddSeconds(2); var remainder = Math.Max(2000, (nextSecond - DateTime.Now).TotalMilliseconds); if (remainder > 0) { System.Threading.Thread.Sleep(TimeSpan.FromMilliseconds(remainder)); } } /// /// Each backup run needs a unique timestamp to avoid the same backup being created. /// During testing we often need to run backups in quick succession, so we need to /// sleep until the next second to make sure the next backup timestamp is not the same. /// /// The results of the previous backup private void SleepUntilNextSecond(IBackupResults backupResults) => SleepUntilNextSecond(backupResults.BeginTime); [Test] [Category("Disruption"), Category("Bug")] public void InterruptedCompact5184() { // Reproduction steps from issue #4129 with smaller sizes var testopts = TestOptions; testopts["backup-test-samples"] = "0"; testopts["number-of-retries"] = "0"; testopts["dblock-size"] = "20KB"; // Reduce blocksize, because with default blocksize too few blocks fit in a volume testopts["blocksize"] = "1KB"; testopts["keep-versions"] = "1"; testopts["no-auto-compact"] = "true"; testopts["threshold"] = "5"; // This size seems to be appropriate to cause significant compacting // in combination with dblock-size const long filesize = 44971; string target = "file://" + TARGETFOLDER; string file1 = Path.Combine(DATAFOLDER, "f1"); string file2 = Path.Combine(DATAFOLDER, "f2"); string file3 = Path.Combine(DATAFOLDER, "f3"); string file4 = Path.Combine(DATAFOLDER, "f4"); string file5 = Path.Combine(DATAFOLDER, "f5"); string file6 = Path.Combine(DATAFOLDER, "f6"); // Add files 1,2 TestUtils.WriteTestFile(file1, filesize); TestUtils.WriteTestFile(file2, filesize); using (var c = new Library.Main.Controller(target, testopts, null)) { IBackupResults backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 3,4 TestUtils.WriteTestFile(file3, filesize); TestUtils.WriteTestFile(file4, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 5,6 TestUtils.WriteTestFile(file5, filesize); TestUtils.WriteTestFile(file6, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Delete 1,3,5 File.Delete(file1); File.Delete(file3); File.Delete(file5); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); } // Deterministic error backend Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { if (firstPutCompleted && action.IsGetOperation) { return true; } if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } return false; }; // Expect error from backend Assert.Catch(() => { using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } }); DeterministicErrorBackend.ErrorGenerator = null; testopts["full-remote-verification"] = "true"; testopts["full-block-verification"] = "true"; target = "file://" + TARGETFOLDER; using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Remove the local database File.Delete(DBFILE); // Repair to recreate the local database using (var c = new Library.Main.Controller(target, testopts, null)) { IRepairResults repairResults = c.Repair(); TestUtils.AssertResults(repairResults); } // Re-do the full verification using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } } [Test] [Category("Disruption"), Category("Bug")] public void InterruptedCompactPlusNormalCompact5184() { // Reproduction steps from issue #4129 with smaller sizes var testopts = TestOptions; testopts["backup-test-samples"] = "0"; testopts["number-of-retries"] = "0"; testopts["dblock-size"] = "20KB"; // Reduce blocksize, because with default blocksize too few blocks fit in a volume testopts["blocksize"] = "1KB"; testopts["keep-versions"] = "1"; testopts["no-auto-compact"] = "true"; testopts["threshold"] = "5"; // This size seems to be appropriate to cause significant compacting // in combination with dblock-size const long filesize = 44971; string target = "file://" + TARGETFOLDER; string file1 = Path.Combine(DATAFOLDER, "f1"); string file2 = Path.Combine(DATAFOLDER, "f2"); string file3 = Path.Combine(DATAFOLDER, "f3"); string file4 = Path.Combine(DATAFOLDER, "f4"); string file5 = Path.Combine(DATAFOLDER, "f5"); string file6 = Path.Combine(DATAFOLDER, "f6"); // Add files 1,2 TestUtils.WriteTestFile(file1, filesize); TestUtils.WriteTestFile(file2, filesize); using (var c = new Library.Main.Controller(target, testopts, null)) { IBackupResults backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 3,4 TestUtils.WriteTestFile(file3, filesize); TestUtils.WriteTestFile(file4, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 5,6 TestUtils.WriteTestFile(file5, filesize); TestUtils.WriteTestFile(file6, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Delete 1,3,5 File.Delete(file1); File.Delete(file3); File.Delete(file5); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); } // Deterministic error backend Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { if (firstPutCompleted && action.IsGetOperation) { return true; } if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } return false; }; // Expect error from backend Assert.Catch(() => { using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } }); DeterministicErrorBackend.ErrorGenerator = null; testopts["full-remote-verification"] = "true"; testopts["full-block-verification"] = "true"; target = "file://" + TARGETFOLDER; using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Make sure we can compact after the interrupted compact using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } // Make sure there are no errors after success compacting using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Remove the local database File.Delete(DBFILE); // Repair to recreate the local database using (var c = new Library.Main.Controller(target, testopts, null)) { IRepairResults repairResults = c.Repair(); TestUtils.AssertResults(repairResults); } // Re-do the full verification using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } } [Test] [Category("Disruption"), Category("Bug")] public void DoubleInterruptedCompact5184() { // Reproduction steps from issue #4129 with smaller sizes var testopts = TestOptions; testopts["backup-test-samples"] = "0"; testopts["number-of-retries"] = "0"; testopts["dblock-size"] = "20KB"; // Reduce blocksize, because with default blocksize too few blocks fit in a volume testopts["blocksize"] = "1KB"; testopts["keep-versions"] = "1"; testopts["no-auto-compact"] = "true"; testopts["threshold"] = "5"; // This size seems to be appropriate to cause significant compacting // in combination with dblock-size const long filesize = 44971; string target = "file://" + TARGETFOLDER; string file1 = Path.Combine(DATAFOLDER, "f1"); string file2 = Path.Combine(DATAFOLDER, "f2"); string file3 = Path.Combine(DATAFOLDER, "f3"); string file4 = Path.Combine(DATAFOLDER, "f4"); string file5 = Path.Combine(DATAFOLDER, "f5"); string file6 = Path.Combine(DATAFOLDER, "f6"); // Add files 1,2 TestUtils.WriteTestFile(file1, filesize); TestUtils.WriteTestFile(file2, filesize); using (var c = new Library.Main.Controller(target, testopts, null)) { IBackupResults backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 3,4 TestUtils.WriteTestFile(file3, filesize); TestUtils.WriteTestFile(file4, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 5,6 TestUtils.WriteTestFile(file5, filesize); TestUtils.WriteTestFile(file6, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Delete 1,3,5 File.Delete(file1); File.Delete(file3); File.Delete(file5); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); } // Deterministic error backend Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { if (firstPutCompleted && action.IsGetOperation) { return true; } if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } return false; }; // Expect error from backend Assert.Catch(() => { using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } }); // Expect error from backend, again Assert.Catch(() => { using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } }); DeterministicErrorBackend.ErrorGenerator = null; testopts["full-remote-verification"] = "true"; testopts["full-block-verification"] = "true"; using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Make sure we can compact after two interrupted compacts using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } // Make sure there are no errors after success compacting using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Remove the local database File.Delete(DBFILE); // Repair to recreate the local database using (var c = new Library.Main.Controller(target, testopts, null)) { IRepairResults repairResults = c.Repair(); TestUtils.AssertResults(repairResults); } // Re-do the full verification using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } } [Test] [Category("Disruption"), Category("Bug")] public void RestoreAfterDoubleInterruptedCompact5184() { // Reproduction steps from issue #4129 with smaller sizes var testopts = TestOptions; testopts["backup-test-samples"] = "0"; testopts["number-of-retries"] = "0"; testopts["dblock-size"] = "20KB"; // Reduce blocksize, because with default blocksize too few blocks fit in a volume testopts["blocksize"] = "1KB"; testopts["keep-versions"] = "1"; testopts["no-auto-compact"] = "true"; testopts["threshold"] = "5"; // This size seems to be appropriate to cause significant compacting // in combination with dblock-size const long filesize = 44971; string target = "file://" + TARGETFOLDER; string file1 = Path.Combine(DATAFOLDER, "f1"); string file2 = Path.Combine(DATAFOLDER, "f2"); string file3 = Path.Combine(DATAFOLDER, "f3"); string file4 = Path.Combine(DATAFOLDER, "f4"); string file5 = Path.Combine(DATAFOLDER, "f5"); string file6 = Path.Combine(DATAFOLDER, "f6"); // Add files 1,2 TestUtils.WriteTestFile(file1, filesize); TestUtils.WriteTestFile(file2, filesize); using (var c = new Library.Main.Controller(target, testopts, null)) { IBackupResults backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 3,4 TestUtils.WriteTestFile(file3, filesize); TestUtils.WriteTestFile(file4, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Add files 5,6 TestUtils.WriteTestFile(file5, filesize); TestUtils.WriteTestFile(file6, filesize); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); SleepUntilNextSecond(backupResults); // Delete 1,3,5 File.Delete(file1); File.Delete(file3); File.Delete(file5); backupResults = c.Backup(new[] { DATAFOLDER }); TestUtils.AssertResults(backupResults); } // Deterministic error backend Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend()); target = new DeterministicErrorBackend().ProtocolKey + "://" + TARGETFOLDER; // Fail the compact after the first dblock put is completed bool firstPutCompleted = false; DeterministicErrorBackend.ErrorGenerator = (DeterministicErrorBackend.BackendAction action, string remotename) => { if (firstPutCompleted && action.IsGetOperation) { return true; } if (action == DeterministicErrorBackend.BackendAction.PutAfter) { firstPutCompleted = true; } return false; }; // Expect error from backend Assert.Catch(() => { using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } }); // Expect error from backend, again Assert.Catch(() => { using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); Assert.Greater(compactResults.DownloadedFileCount, 0, "No compact operation was performed"); } }); DeterministicErrorBackend.ErrorGenerator = null; target = "file://" + TARGETFOLDER; testopts["full-remote-verification"] = "true"; testopts["full-block-verification"] = "true"; using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Remove the local database File.Delete(DBFILE); // Repair to recreate the local database using (var c = new Library.Main.Controller(target, testopts, null)) { IRepairResults repairResults = c.Repair(); TestUtils.AssertResults(repairResults); } // Re-do the full verification using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } // Compact without issues on the repaired database using (var c = new Library.Main.Controller(target, testopts, null)) { ICompactResults compactResults = c.Compact(); if (compactResults.DownloadedFileCount == 0) Assert.Inconclusive("No compact operation was performed"); } // Make sure there are no errors after success compacting using (var c = new Library.Main.Controller(target, testopts, null)) { ITestResults testResults = c.Test(long.MaxValue); // Expect no verification errors Assert.IsTrue(testResults.Verifications.All(v => !v.Value.Any()), "Test verification failed:\n {0}", VerificationToString(testResults.Verifications)); } } } }