// 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. #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Main; using Duplicati.Library.Main.Database; using Duplicati.Library.Main.Operation; using Duplicati.Library.SQLiteHelper; using Duplicati.Library.Utility; using IndexVolumeWriter = Duplicati.Library.Main.Volumes.IndexVolumeWriter; using IRemoteVolume = Duplicati.Library.Main.Database.IRemoteVolume; using VolumeWriterBase = Duplicati.Library.Main.Volumes.VolumeWriterBase; using InterfaceFileEntry = Duplicati.Library.Interface.IFileEntry; using NUnit.Framework; using NUnit.Framework.Legacy; namespace Duplicati.UnitTest { [TestFixture] public class SetLocksHandlerTests : BasicSetupHelper { private sealed class FakeLockingBackendManager : IBackendManager { private readonly bool m_failFirstLock; private bool m_hasFailed; public FakeLockingBackendManager(bool failFirstLock = false) { m_failFirstLock = failFirstLock; } public List<(string Name, DateTime UntilUtc)> LockedVolumes { get; } = new(); public bool WaitedForEmpty { get; private set; } public bool SupportsObjectLocking => true; public Task SetObjectLockUntilAsync(string remotename, DateTime lockUntilUtc, CancellationToken cancelToken) { LockedVolumes.Add((remotename, lockUntilUtc)); if (m_failFirstLock && !m_hasFailed) { m_hasFailed = true; throw new InvalidOperationException("Simulated lock failure"); } return Task.CompletedTask; } public Task GetObjectLockUntilAsync(string remotename, CancellationToken cancelToken) => Task.FromResult(LockedVolumes.FirstOrDefault(x => x.Name == remotename).UntilUtc); public Task WaitForEmptyAsync(CancellationToken cancellationToken) { WaitedForEmpty = true; return Task.CompletedTask; } public Task WaitForEmptyAsync(LocalDatabase database, CancellationToken cancellationToken) => WaitForEmptyAsync(cancellationToken); public void Dispose() { } #region Unused interface members public Task PutAsync(VolumeWriterBase blockVolume, IndexVolumeWriter? indexVolume, Func? indexVolumeFinished, bool waitForComplete, Func? onDbUpdate, CancellationToken cancelToken) => throw new NotImplementedException(); public Task PutVerificationFileAsync(string remotename, TempFile tempFile, CancellationToken cancelToken) => throw new NotImplementedException(); public Task> ListAsync(CancellationToken cancelToken) => throw new NotImplementedException(); public TempFile DecryptFile(TempFile volume, string volume_name, Options options) => throw new NotImplementedException(); public Task DeleteAsync(string remotename, long size, bool waitForComplete, CancellationToken cancelToken) => throw new NotImplementedException(); public Task GetQuotaInfoAsync(CancellationToken cancelToken) => throw new NotImplementedException(); public Task<(TempFile File, string Hash, long Size)> GetWithInfoAsync(string remotename, string hash, long size, CancellationToken cancelToken) => throw new NotImplementedException(); public Task GetAsync(string remotename, string hash, long size, CancellationToken cancelToken) => throw new NotImplementedException(); public Task GetDirectAsync(string remotename, string hash, long size, CancellationToken cancelToken) => throw new NotImplementedException(); public IAsyncEnumerable<(TempFile File, string Hash, long Size, string Name)> GetFilesOverlappedAsync(IEnumerable volumes, CancellationToken cancelToken) => throw new NotImplementedException(); public Task FlushPendingMessagesAsync(LocalDatabase database, CancellationToken cancellationToken) => Task.CompletedTask; public void UpdateThrottleValues(long maxUploadPrSecond, long maxDownloadPrSecond) => throw new NotImplementedException(); #endregion } [SetUp] public void SetUp() { File.WriteAllText(Path.Combine(DATAFOLDER, "file.txt"), "content"); } [Test] [Category("LockHandler")] public async Task AppliesLocksForVolumesInFileset() { var options = new Dictionary(TestOptions); using (var controller = new Controller("file://" + TARGETFOLDER, options, null)) { controller.Backup([DATAFOLDER]); } var lockDbPath = Path.Combine(BASEFOLDER, $"locktest-{Guid.NewGuid():N}.sqlite"); File.Copy(options["dbpath"], lockDbPath, true); await using var db = await LocalLockDatabase.CreateAsync(lockDbPath, null, CancellationToken.None).ConfigureAwait(false); var filesets = new List>(); await foreach (var entry in db.FilesetTimes(CancellationToken.None).ConfigureAwait(false)) filesets.Add(entry); var targetFileset = filesets.Last(); var expectedVolumes = new List(); await foreach ((var volume, _) in db.GetRemoteVolumesDependingOnFilesets(new[] { targetFileset.Key }, CancellationToken.None).ConfigureAwait(false)) expectedVolumes.Add(volume); var lockingOptions = new Options(new Dictionary(options.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)) { ["dbpath"] = lockDbPath, ["remote-file-lock-duration"] = "1D", }); var backend = new FakeLockingBackendManager(); var handler = new SetLocksHandler(lockingOptions, new SetLockResults(), new[] { targetFileset.Value }); await handler.RunAsync(backend, db).ConfigureAwait(false); CollectionAssert.AreEquivalent(expectedVolumes, backend.LockedVolumes.Select(x => x.Name)); ClassicAssert.True(backend.WaitedForEmpty); } [Test] [Category("LockHandler")] public async Task ContinuesWhenLockingFails() { var options = new Dictionary(TestOptions); using (var controller = new Controller("file://" + TARGETFOLDER, options, null)) { controller.Backup([DATAFOLDER]); } var lockDbPath = Path.Combine(BASEFOLDER, $"locktest-{Guid.NewGuid():N}.sqlite"); File.Copy(options["dbpath"], lockDbPath, true); await using var db = await LocalLockDatabase.CreateAsync(lockDbPath, null, CancellationToken.None).ConfigureAwait(false); var filesets = new List>(); await foreach (var entry in db.FilesetTimes(CancellationToken.None).ConfigureAwait(false)) filesets.Add(entry); var latestFileset = filesets.Last(); var expectedVolumes = new List(); await foreach ((var volume, _) in db.GetRemoteVolumesDependingOnFilesets(new[] { latestFileset.Key }, CancellationToken.None).ConfigureAwait(false)) expectedVolumes.Add(volume); var lockingOptions = new Options(new Dictionary(options.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)) { ["dbpath"] = lockDbPath, ["remote-file-lock-duration"] = "1D", }); var backend = new FakeLockingBackendManager(failFirstLock: true); var handler = new SetLocksHandler(lockingOptions, new SetLockResults(), new[] { latestFileset.Value }); Assert.DoesNotThrowAsync(() => handler.RunAsync(backend, db)); CollectionAssert.AreEquivalent(expectedVolumes, backend.LockedVolumes.Select(x => x.Name)); ClassicAssert.True(backend.WaitedForEmpty); } /// /// Tests that GetRemoteVolumesDependingOnFilesets works correctly with a large number of fileset IDs /// that triggers the temporary table code path (when count > CHUNK_SIZE = 128). /// [Test] [Category("Database")] public async Task GetRemoteVolumesDependingOnFilesets_WithLargeInput_UsesTemporaryTable() { using var dbfile = new TempFile(); using var db = SQLiteLoader.LoadConnection(dbfile); // Use DatabaseUpgrader to create the schema from embedded resources DatabaseUpgrader.UpgradeDatabase(db, dbfile, typeof(DatabaseSchemaMarker)); using var cmd = db.CreateCommand(); // Insert an operation record (required for LocalDatabase initialization) cmd.CommandText = @"INSERT INTO ""Operation"" (""Description"", ""Timestamp"") VALUES ('Test', 0)"; cmd.ExecuteNonQuery(); // Create 150 fileset IDs (exceeds CHUNK_SIZE of 128) to trigger temporary table path var filesetIds = new List(); for (int i = 0; i < 150; i++) { // Insert a remote volume for each fileset cmd.CommandText = $@" INSERT INTO ""Remotevolume"" (""ID"", ""OperationID"", ""Name"", ""Type"", ""State"", ""VerificationCount"", ""DeleteGraceTime"", ""ArchiveTime"", ""LockExpirationTime"") VALUES ({i + 1}, 1, 'volume-{i}.zip', 'Files', 'Verified', 0, 0, 0, 0)"; cmd.ExecuteNonQuery(); // Insert a fileset referencing this volume cmd.CommandText = $@" INSERT INTO ""Fileset"" (""ID"", ""OperationID"", ""VolumeID"", ""IsFullBackup"", ""Timestamp"") VALUES ({i + 1}, 1, {i + 1}, 1, {i})"; cmd.ExecuteNonQuery(); filesetIds.Add(i + 1); } // Close the connection so LocalDatabase can open it db.Close(); // Create LocalDatabase instance await using var localDb = await LocalDatabase.CreateLocalDatabaseAsync( dbfile, "TestOperation", true, null, CancellationToken.None ); // Act: Call GetRemoteVolumesDependingOnFilesets with 150 fileset IDs // This should trigger the temporary table code path var volumes = new List(); await foreach ((var volume, _) in localDb.GetRemoteVolumesDependingOnFilesets(filesetIds, CancellationToken.None).ConfigureAwait(false)) volumes.Add(volume); // Assert: Should return all 150 volumes Assert.That(volumes.Count, Is.EqualTo(150)); for (int i = 0; i < 150; i++) Assert.That(volumes, Does.Contain($"volume-{i}.zip")); } } }