// 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.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Utility; using Microsoft.Data.Sqlite; namespace Duplicati.Library.Main.Database { /// /// A local database for purging files from a fileset. /// internal class LocalPurgeDatabase : LocalDeleteDatabase { /// /// Creates a new instance of the class. /// /// The path to the database file. /// The optional existing database instance to use. Used to mimic constructor chaining. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(string path, LocalPurgeDatabase? dbnew, CancellationToken token) { dbnew ??= new LocalPurgeDatabase(); dbnew = (LocalPurgeDatabase) await LocalDeleteDatabase.CreateAsync(path, "Purge", dbnew, token) .ConfigureAwait(false); dbnew.ShouldCloseConnection = true; return dbnew; } /// /// Creates a new instance of the class. /// /// The parent database to use. /// An optional existing database instance to use. Used to mimic constructor chaining. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(LocalDatabase dbparent, LocalPurgeDatabase? dbnew, CancellationToken token) { dbnew ??= new LocalPurgeDatabase(); dbnew = (LocalPurgeDatabase) await LocalDeleteDatabase.CreateAsync(dbparent, dbnew, token) .ConfigureAwait(false); return dbnew; } /// /// Creates a new instance of the class. /// /// The ID of the parent fileset. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public async Task CreateTemporaryFileset(long parentid, CancellationToken token) { return await TemporaryFileset.CreateAsync(parentid, this, token) .ConfigureAwait(false); } /// /// Gets the remote volume name for a fileset with the specified ID. /// /// The ID of the fileset. /// A cancellation token to cancel the operation. /// A task that when awaited contains the name of the remote volume. public async Task GetRemoteVolumeNameForFileset(long id, CancellationToken token) { await using var cmd = m_connection.CreateCommand(@" SELECT ""B"".""Name"" FROM ""Fileset"" ""A"", ""RemoteVolume"" ""B"" WHERE ""A"".""VolumeID"" = ""B"".""ID"" AND ""A"".""ID"" = @FilesetId ") .SetParameterValue("@FilesetId", id); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); if (!await rd.ReadAsync(token).ConfigureAwait(false)) throw new Exception($"No remote volume found for fileset with id {id}"); else return rd.ConvertValueToString(0) ?? throw new Exception($"Remote volume name for fileset with id {id} is null"); } /// /// Counts the number of orphan files in the database. /// /// A cancellation token to cancel the operation. /// A task that when awaited contains the count of orphan files. internal async Task CountOrphanFiles(CancellationToken token) { await using var cmd = m_connection.CreateCommand(m_rtr); cmd.SetCommandAndParameters(@" SELECT COUNT(*) FROM ""FileLookup"" WHERE ""ID"" NOT IN ( SELECT DISTINCT ""FileID"" FROM ""FilesetEntry"" ) "); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) return rd.ConvertValueToInt64(0, 0); else return 0; } /// /// Interface for a temporary fileset used for purging files from a fileset. /// public interface ITemporaryFileset : IDisposable, IAsyncDisposable { /// /// Gets the ID of the parent fileset. /// long ParentID { get; } /// /// Gets the count of removed files. /// long RemovedFileCount { get; } /// /// Gets the total size of removed files. /// long RemovedFileSize { get; } /// /// Gets the count of updated files. /// long UpdatedFileCount { get; } /// /// Applies a filter to the temporary fileset. /// /// The filter to apply. /// A cancellation token to cancel the operation. /// A task that completes when the filter has been applied. Task ApplyFilter(Library.Utility.IFilter filter, CancellationToken token); /// /// Applies a filter using a custom command. /// /// The command to execute for filtering. /// A cancellation token to cancel the operation. /// A task that completes when the filter has been applied. Task ApplyFilter(Func> filtercommand, CancellationToken token); /// /// Converts the temporary fileset to a permanent fileset. /// /// The name of the new fileset. /// The timestamp for the new fileset. /// Indicates if this is a full backup. /// A cancellation token to cancel the operation. /// A task that when awaited contains a tuple with the remote volume ID and the new fileset ID. Task> ConvertToPermanentFileset(string name, DateTime timestamp, bool isFullBackup, CancellationToken token); /// /// Lists all deleted files in the temporary fileset. /// /// A cancellation token to cancel the operation. /// An asynchronous enumerable of key-value pairs where the key is the file path and the value is the file size. IAsyncEnumerable> ListAllDeletedFiles(CancellationToken token); } /// /// A temporary fileset implementation for purging files from a fileset. /// private class TemporaryFileset : ITemporaryFileset { /// /// The name of the temporary table used for storing deleted files. /// private string m_tablename = null!; /// /// The database instance used for queries. /// private LocalDatabase m_db = null!; public long ParentID { get; private set; } public long RemovedFileCount { get; private set; } public long RemovedFileSize { get; private set; } public long UpdatedFileCount { get; private set; } /// /// Calling this constructor will throw an exception. Use CreateAsync instead. /// [Obsolete("Calling this constructor will throw an exception. Use CreateAsync instead.")] public TemporaryFileset(long parentid, LocalPurgeDatabase parentdb, SqliteConnection connection, SqliteTransaction transaction) { throw new NotImplementedException("Use CreateAsync instead"); } /// /// Private constructor to prevent direct instantiation. /// private TemporaryFileset() { } /// /// Creates a new instance of the class asynchronously. /// /// The ID of the parent fileset. /// The database instance to use. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(long parentid, LocalDatabase db, CancellationToken token) { var tempf = new TemporaryFileset() { ParentID = parentid, m_db = db, m_tablename = "TempDeletedFilesTable-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()) }; await using (var cmd = db.Connection.CreateCommand()) await cmd.ExecuteNonQueryAsync($@" CREATE TEMPORARY TABLE ""{tempf.m_tablename}"" ( ""FileID"" INTEGER PRIMARY KEY ) ", token) .ConfigureAwait(false); return tempf; } /// /// Applies a filter to the temporary fileset using a custom command. /// /// The command to execute for filtering. /// A cancellation token to cancel the operation. /// A task that completes when the filter has been applied. public async Task ApplyFilter(Func> filtercommand, CancellationToken token) { int updated; await using (var cmd = m_db.Connection.CreateCommand()) updated = await filtercommand(cmd, ParentID, m_tablename) .ConfigureAwait(false); await PostFilterChecks(updated, token).ConfigureAwait(false); } /// /// Applies a filter to the temporary fileset. /// /// The filter to apply. /// A cancellation token to cancel the operation. /// A task that completes when the filter has been applied. public async Task ApplyFilter(Library.Utility.IFilter filter, CancellationToken token) { if (Library.Utility.Utility.IsFSCaseSensitive && filter is FilterExpression expression && expression.Type == Duplicati.Library.Utility.FilterType.Simple) { // File list based // unfortunately we cannot do this if the filesystem is not case-sensitive as // SQLite only supports ASCII compares var p = expression.GetSimpleList(); var filenamestable = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray()); await using var cmd = m_db.Connection.CreateCommand(); await cmd.ExecuteNonQueryAsync($@" CREATE TEMPORARY TABLE ""{filenamestable}"" ( ""Path"" TEXT NOT NULL ) ", token) .ConfigureAwait(false); await cmd.SetCommandAndParameters($@" INSERT INTO ""{filenamestable}"" (""Path"") VALUES (@Path) ") .PrepareAsync(token) .ConfigureAwait(false); foreach (var s in p) await cmd.SetParameterValue("@Path", s) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); await cmd.SetCommandAndParameters($@" INSERT INTO ""{m_tablename}"" (""FileID"") SELECT DISTINCT ""A"".""FileID"" FROM ""FilesetEntry"" ""A"", ""File"" ""B"" WHERE ""A"".""FilesetID"" = @FilesetId AND ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""Path"" IN ""{filenamestable}"" ") .SetParameterValue("@FilesetId", ParentID) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); await cmd .ExecuteNonQueryAsync($@"DROP TABLE IF EXISTS ""{filenamestable}"" ", token) .ConfigureAwait(false); } else { // Do row-wise iteration var values = new object[2]; await using var cmd = m_db.Connection.CreateCommand(); await using var cmd2 = m_db.Connection.CreateCommand(); await cmd2.SetCommandAndParameters($@" INSERT INTO ""{m_tablename}"" (""FileID"") VALUES (@FileId) ") .PrepareAsync(token) .ConfigureAwait(false); cmd.SetCommandAndParameters(@" SELECT ""B"".""Path"", ""A"".""FileID"" FROM ""FilesetEntry"" ""A"", ""File"" ""B"" WHERE ""A"".""FilesetID"" = @FilesetId AND ""A"".""FileID"" = ""B"".""ID"" ") .SetParameterValue("@FilesetId", ParentID); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) { rd.GetValues(values); var path = values[0] as string; if (path != null && Library.Utility.FilterExpression.Matches(filter, path.ToString())) { await cmd2 .SetParameterValue("@FileId", values[1]) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); } } } await PostFilterChecks(0, token).ConfigureAwait(false); } /// /// Applies the filter checks after filtering. /// /// The number of files updated by the filter. /// A cancellation token to cancel the operation. /// A task that completes when the checks have been applied. private async Task PostFilterChecks(int updated, CancellationToken token) { await using var cmd = m_db.Connection.CreateCommand(); UpdatedFileCount = updated; RemovedFileCount = await cmd.ExecuteScalarInt64Async($@" SELECT COUNT(*) FROM ""{m_tablename}"" ", 0, token) .ConfigureAwait(false); RemovedFileSize = await cmd.ExecuteScalarInt64Async($@" SELECT SUM(""C"".""Length"") FROM ""{m_tablename}"" ""A"", ""FileLookup"" ""B"", ""Blockset"" ""C"", ""Metadataset"" ""D"" WHERE ""A"".""FileID"" = ""B"".""ID"" AND ( ""B"".""BlocksetID"" = ""C"".""ID"" OR ( ""B"".""MetadataID"" = ""D"".""ID"" AND ""D"".""BlocksetID"" = ""C"".""ID"" ) ) ", 0, token) .ConfigureAwait(false); var filesetcount = await cmd.SetCommandAndParameters($@" SELECT COUNT(*) FROM ""FilesetEntry"" WHERE ""FilesetID"" = @ParentID ") .SetParameterValue("@ParentID", ParentID) .ExecuteScalarInt64Async(0, token) .ConfigureAwait(false); if (filesetcount == RemovedFileCount) throw new Interface.UserInformationException($"Refusing to purge {RemovedFileCount} files from fileset with ID {ParentID}, as that would remove the entire fileset.\nTo delete a fileset, use the \"delete\" command.", "PurgeWouldRemoveEntireFileset"); } /// public async Task> ConvertToPermanentFileset(string name, DateTime timestamp, bool isFullBackup, CancellationToken token) { var remotevolid = await m_db .RegisterRemoteVolume(name, RemoteVolumeType.Files, RemoteVolumeState.Temporary, token) .ConfigureAwait(false); var filesetid = await m_db .CreateFileset(remotevolid, timestamp, token) .ConfigureAwait(false); await m_db .UpdateFullBackupStateInFileset(filesetid, isFullBackup, token) .ConfigureAwait(false); await using (var cmd = m_db.Connection.CreateCommand(m_db.Transaction)) await cmd.SetCommandAndParameters($@" INSERT INTO ""FilesetEntry"" ( ""FilesetID"", ""FileID"", ""Lastmodified"" ) SELECT @TargetFilesetId, ""FileID"", ""LastModified"" FROM ""FilesetEntry"" WHERE ""FilesetID"" = @SourceFilesetId AND ""FileID"" NOT IN ""{m_tablename}"" ") .SetParameterValue("@TargetFilesetId", filesetid) .SetParameterValue("@SourceFilesetId", ParentID) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); return new Tuple(remotevolid, filesetid); } /// public async IAsyncEnumerable> ListAllDeletedFiles([EnumeratorCancellation] CancellationToken token) { await using var cmd = m_db.Connection.CreateCommand(); await using var rd = await cmd.ExecuteReaderAsync($@" SELECT ""B"".""Path"", ""C"".""Length"" FROM ""{m_tablename}"" ""A"", ""File"" ""B"", ""Blockset"" ""C"" WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" ", token) .ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) yield return new KeyValuePair( rd.ConvertValueToString(0) ?? "", rd.ConvertValueToInt64(1) ); } public void Dispose() { DisposeAsync().AsTask().Await(); } public async ValueTask DisposeAsync() { try { await using var cmd = m_db.Connection.CreateCommand(); await cmd .ExecuteNonQueryAsync($@"DROP TABLE IF EXISTS ""{m_tablename}""", default) .ConfigureAwait(false); } catch { } } } } }