// 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 NUnit.Framework; using System; using System.IO; using System.Linq; using System.Collections.Generic; using Microsoft.Data.Sqlite; using Assert = NUnit.Framework.Legacy.ClassicAssert; using System.Threading.Tasks; #nullable enable namespace Duplicati.UnitTest { public class CompactBlockVolumeIdConstraintTests : BasicSetupHelper { /// /// Attempt to reproduce a crash when compacting a database with a controller sequence /// [Test] [Category("Disruption"), Category("Bug")] public async Task CompactWithControllerSequence_ShouldNotCrash_Async() { var testopts = new Dictionary(TestOptions) { ["backup-test-samples"] = "0", ["number-of-retries"] = "0", ["dblock-size"] = "10KB", ["blocksize"] = "1KB", ["synchronous-upload"] = "true" }; string target = "file://" + TARGETFOLDER; string smallFile = Path.Combine(DATAFOLDER, "small.bin"); string largeFile = Path.Combine(DATAFOLDER, "large.bin"); byte[] smallContent = new byte[1024]; new Random(42).NextBytes(smallContent); File.WriteAllBytes(smallFile, smallContent); byte[] largeContent = new byte[25 * 1024]; Array.Copy(smallContent, largeContent, 1024); new Random(43).NextBytes(largeContent.AsSpan(1024, largeContent.Length - 1024)); File.WriteAllBytes(largeFile, largeContent); using (var c = new Library.Main.Controller(target, testopts, null)) await c.BackupAsync(new[] { DATAFOLDER }); File.Delete(largeFile); testopts["no-auto-compact"] = "true"; testopts["keep-versions"] = "1"; testopts["allow-full-removal"] = "true"; using (var c = new Library.Main.Controller(target, testopts, null)) await c.BackupAsync(new[] { DATAFOLDER }); testopts.Remove("no-auto-compact"); testopts["threshold"] = "5"; // Run compact, it should not crash. using (var c = new Library.Main.Controller(target, testopts, null)) await c.CompactAsync(); } /// /// This test follows a natural backup flow and then simulates a database /// inconsistency where blocks exist in both Block and DeletedBlock tables. /// /// The normal backup/delete flow atomically moves blocks between these tables, /// so a purely natural sequence cannot create the overlapping state. However, /// this test replicates the state that would occur if cleanup failed, by /// using a targeted SQL statement after the controller sequence. /// [Test] [Category("Disruption"), Category("Bug")] public async Task CompactWithControllerSequence_AddBackFile_TriggersNotNullConstraint_Async() { var testopts = new Dictionary(TestOptions) { ["backup-test-samples"] = "0", ["number-of-retries"] = "0", ["dblock-size"] = "10KB", ["blocksize"] = "1KB", ["synchronous-upload"] = "true", ["no-auto-compact"] = "true" }; string target = "file://" + TARGETFOLDER; string smallFile = Path.Combine(DATAFOLDER, "small.bin"); string largeFile = Path.Combine(DATAFOLDER, "large.bin"); byte[] smallContent = new byte[1024]; new Random(42).NextBytes(smallContent); File.WriteAllBytes(smallFile, smallContent); byte[] largeContent = new byte[25 * 1024]; Array.Copy(smallContent, largeContent, 1024); new Random(43).NextBytes(largeContent.AsSpan(1024, largeContent.Length - 1024)); File.WriteAllBytes(largeFile, largeContent); // Step 1: Backup with both files using (var c = new Library.Main.Controller(target, testopts, null)) await c.BackupAsync(new[] { DATAFOLDER }); // Step 2: Remove large file and backup (deletes old version, moves large blocks to DeletedBlock) File.Delete(largeFile); testopts["keep-versions"] = "1"; testopts["allow-full-removal"] = "true"; using (var c = new Library.Main.Controller(target, testopts, null)) await c.BackupAsync(new[] { DATAFOLDER }); // Step 3: Add large file back and backup (moves large blocks back to Block) File.WriteAllBytes(largeFile, largeContent); using (var c = new Library.Main.Controller(target, testopts, null)) await c.BackupAsync(new[] { DATAFOLDER }); // Simulate the faulty database state that the natural flow does not create: // blocks exist in both Block and DeletedBlock with matching Hash/Size/VolumeID. // This replicates what would happen if a cleanup operation failed to remove // entries from DeletedBlock after moving them back to Block. using (var db = new SqliteConnection($"Data Source={DBFILE};Pooling=False")) { await db.OpenAsync(); using (var cmd = db.CreateCommand()) { cmd.CommandText = @" INSERT OR IGNORE INTO ""DeletedBlock"" (""Hash"", ""Size"", ""VolumeID"") SELECT ""Hash"", ""Size"", ""VolumeID"" FROM ""Block""; "; await cmd.ExecuteNonQueryAsync(); } } testopts.Remove("no-auto-compact"); testopts["threshold"] = "5"; // Step 4: Compact - this triggers the bug in the unfixed codebase. using (var c = new Library.Main.Controller(target, testopts, null)) { Exception? caughtException = null; try { await c.CompactAsync(); } catch (Exception ex) { caughtException = ex; if (caughtException is AggregateException aggEx && aggEx.InnerExceptions.Count > 0) caughtException = aggEx.InnerExceptions.First(); if (caughtException is SqliteException sqlEx && sqlEx.SqliteErrorCode == 19) { Assert.Fail("The NOT NULL constraint error was triggered! The bug is still present: " + caughtException.Message); } } if (caughtException == null) { Assert.Fail("Expected an exception to be thrown due to the faulty DB state, but none was."); } else if (caughtException.Message.Contains("Unexpected number of rows updated")) { Assert.Pass("The code safely handled the faulty database state and threw an unexpected rows exception instead of a NOT NULL constraint."); } else { // For debugging other failures Assert.Fail($"Threw unexpected exception: {caughtException}"); } } } } }