// 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. using System.CommandLine; using System.CommandLine.NamingConventionBinder; using Duplicati.Library.AutoUpdater; using Duplicati.Library.Interface; using Duplicati.Library.Main.Database; using Duplicati.Library.SQLiteHelper; using Duplicati.Library.Utility; namespace Duplicati.CommandLine.DatabaseTool.Commands; /// /// The downgrade command /// public static class Downgrade { /// /// Creates the downgrade command /// /// The downgrade command public static Command Create() => new Command("downgrade", "Downgrades one or more databases to a prior version") { new Argument("databases", "The databases to downgrade") { Arity = ArgumentArity.ZeroOrMore }, new Option("--server-datafolder", description: "The folder with databases", getDefaultValue: () => new DirectoryInfo(DataFolderLocator.GetDefaultStorageFolder(DataFolderManager.SERVER_DATABASE_FILENAME, false, true))), new Option("--server-version", description: "The version to downgrade the server database to", getDefaultValue: () => 8), new Option("--local-version", description: "The version to downgrade local databases to", getDefaultValue: () => 14), new Option("--no-backups", description: "Do not create backups before downgrade", getDefaultValue: () => false), new Option("--include-untracked-databases", description: "Include untracked databases in the downgrade process", getDefaultValue: () => false) } .WithHandler(CommandHandler.Create((databases, serverdatafolder, serverversion, localversion, nobackups, includeuntrackeddatabases) => { databases = Helper.FindAllDatabases(databases, serverdatafolder.FullName, includeuntrackeddatabases).Await(); if (databases.Length == 0) { Console.WriteLine("No databases found to downgrade"); return; } static string ReadStream(string path) { using var reader = new StreamReader(typeof(Downgrade).Assembly.GetManifestResourceStream(path) ?? throw new InvalidOperationException()); return reader.ReadToEnd(); } static string ResourceNameToPath(string resourcename) { var prefixes = new[] { typeof(Program).Namespace + ".Scripts.Local.", typeof(Program).Namespace + ".Scripts.Server." }; foreach (var prefix in prefixes) if (resourcename.StartsWith(prefix)) return resourcename.Substring(prefix.Length); throw new Exception($"Unexpected resource name: {resourcename}"); } var serverVersions = typeof(Downgrade).Assembly.GetManifestResourceNames() .Where(x => x.Contains(".Scripts.Server.")) .Select(x => new DowngradeScript(Version: int.Parse(ResourceNameToPath(x).Split('.').First()), Filename: ResourceNameToPath(x), Content: ReadStream(x))) .ToList(); var localVersions = typeof(Downgrade).Assembly.GetManifestResourceNames() .Where(x => x.Contains(".Scripts.Local.")) .Select(x => new DowngradeScript(Version: int.Parse(ResourceNameToPath(x).Split('.').First()), Filename: ResourceNameToPath(x), Content: ReadStream(x))) .ToList(); foreach (var db in databases) { Console.WriteLine($"Examining {db} ..."); if (!File.Exists(db)) { Console.WriteLine($"Database {db} does not exist"); continue; } int version; bool isserverdb; try { (version, isserverdb) = Helper.ExamineDatabase(db).Await(); } catch (Exception ex) { Console.WriteLine($"Error reading database {db}: {ex.Message}"); continue; } Console.WriteLine($"Database {db} is version {version} and is a {(isserverdb ? "server" : "local")} database"); ApplyDowngrade(db, version, isserverdb ? serverversion : localversion, isserverdb ? serverVersions : localVersions, nobackups) .Await(); } })); /// /// The downgrade script /// /// The path to the assembly resource /// The script version /// The script contents private sealed record DowngradeScript(string Filename, int Version, string Content); /// /// Applies the downgrade scripts to the database /// /// The database path /// The current database version /// The target database version /// The downgrade scripts /// Flag to disable backups /// A task that completes when the downgrade is done. private static async Task ApplyDowngrade(string db, int dbversion, int targetversion, IEnumerable scripts, bool nobackups) { if (dbversion > targetversion) { var requiredVersions = Enumerable.Range(targetversion + 1, dbversion - targetversion) .Reverse() .ToList(); var missingScripts = requiredVersions .Where(x => !scripts.Any(y => y.Version == x)) .ToList(); if (missingScripts.Count > 0) { Console.WriteLine($"Missing downgrade scripts for versions: {string.Join(", ", missingScripts)}"); return; } var downgradeScripts = requiredVersions .Select(x => scripts.First(y => y.Version == x)) .ToList(); Console.WriteLine($"Downgrading {db} from version {dbversion} to {targetversion}"); if (!nobackups) Helper.CreateFileBackup(db); await using var con = await SQLiteLoader.LoadConnectionAsync(db) .ConfigureAwait(false); await using var tr = con.BeginTransaction(); await using var cmd = con.CreateCommand(tr); foreach (var script in downgradeScripts) { Console.WriteLine($"Applying downgrade script {script.Filename} ..."); try { cmd.SetCommandAndParameters(script.Content); var r = await cmd.ExecuteScalarAsync().ConfigureAwait(false); if (r != null) throw new UserInformationException($"{r}", "DowngradeScriptFailure"); await cmd.SetCommandAndParameters(@" UPDATE ""Version"" SET ""Version"" = @Version ") .SetParameterValue("@Version", script.Version - 1) .ExecuteNonQueryAsync() .ConfigureAwait(false); } catch (Exception ex) { throw new UserInformationException($"Failed applying downgrade script {script.Filename}: {ex.Message}", "DowngradeScriptFailure", ex); } } await tr.CommitAsync().ConfigureAwait(false); Console.WriteLine($"Downgraded {db} to version {targetversion}"); } else if (dbversion == targetversion) { Console.WriteLine($"Database {db} is already at version {targetversion}"); } else { Console.WriteLine($"Database {db} is already at version {dbversion} (cannot downgrade to {targetversion})"); } } }