// 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 System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; using Duplicati.Library.Main.Database; using Duplicati.Library.Utility; using System.Threading; namespace Duplicati.Library.Main { public class Utility { /// /// The sentinel value used to indicate that encryption is disabled /// private const string NoEncryptionSentinel = "no-encryption"; /// /// The mapping between the options and the database keys /// /// /// The tuple contains the following elements: /// 1. The key used in the database /// 2. The key used in the options /// 3. A function to read the value from the parsed options /// 4. A function to write the value to the parsed options /// private static readonly (string DatabaseKey, string OptionKey, Func GetValue, Func RestoreValue)[] PersistedOptionMappings = [ ("prefix", "prefix", options => options.Prefix, value => value), ("blocksize", "blocksize", options => options.Blocksize.ToString(), value => value + "b"), ("blockhash", "block-hash-algorithm", options => options.BlockHashAlgorithm, value => value), ("filehash", "file-hash-algorithm", options => options.FileHashAlgorithm, value => value), ("dblock-size", "dblock-size", options => options.VolumeSize.ToString(), value => value + "b"), ("compression-module", "compression-module", options => options.CompressionModule, value => value), ("encryption-module", "encryption-module", options => options.NoEncryption ? null : options.EncryptionModule, value => value) ]; /// /// Keys that are persisted in the database /// private static readonly string[] PersistedOptionKeys = [ .. PersistedOptionMappings.Select(option => option.DatabaseKey), "passphrase", "passphrase-salt" ]; /// /// Implementation of the IMetahash interface /// private class Metahash : IMetahash { /// /// The base64 encoded hash /// private readonly string m_filehash; /// /// The UTF-8 encoded json element with the metadata /// private readonly byte[] m_blob; /// /// The lookup table with elements /// private readonly Dictionary m_values; /// /// Constructs a new metahash instance /// /// The values to include in the metahash /// The options to use for hashing public Metahash(Dictionary values, Options options) { m_values = values; using (var ms = new MemoryStream()) using (var w = new StreamWriter(ms, Encoding.UTF8)) using (var filehasher = HashFactory.CreateHasher(options.FileHashAlgorithm)) { if (filehasher == null) throw new Interface.UserInformationException(Strings.Common.InvalidHashAlgorithm(options.FileHashAlgorithm), "FileHashAlgorithmNotSupported"); w.Write(JsonConvert.SerializeObject(values)); w.Flush(); m_blob = ms.ToArray(); ms.Position = 0; m_filehash = Convert.ToBase64String(filehasher.ComputeHash(ms)); } } /// public string FileHash => m_filehash; /// public byte[] Blob => m_blob; /// public Dictionary Values => m_values; } /// /// Constructs a container for a given metadata dictionary /// /// The metadata values to wrap /// A IMetahash instance public static IMetahash WrapMetadata(Dictionary values, Options options) => new Metahash(values, options); /// /// Updates the options with settings from the data, if any. /// /// The database to read from. /// The options to update. /// A cancellation token to cancel the operation. /// A task that completes when the options have been updated. internal static async Task UpdateOptionsFromDb(LocalDatabase db, Options options, CancellationToken cancellationToken) { var opts = await db.GetDbOptions(cancellationToken).ConfigureAwait(false); foreach (var option in PersistedOptionMappings) RestoreOptionFromDb(opts, options, option.DatabaseKey, option.OptionKey, option.RestoreValue); if (opts.TryGetValue("passphrase", out var passphraseState) && passphraseState == NoEncryptionSentinel && !HasExplicitOptionValue(options, "no-encryption") && !HasExplicitOptionValue(options, "passphrase")) options.RawOptions["no-encryption"] = "true"; } /// /// Checks if the database contains options that need to be verified, such as the blocksize. /// /// The database to check. /// A cancellation token to cancel the operation. /// A task that when awaited returns true if the database contains options that need to be verified; false otherwise. internal static async Task ContainsOptionsForVerification(LocalDatabase db, CancellationToken cancellationToken) { var opts = await db.GetDbOptions(cancellationToken).ConfigureAwait(false); await db.Transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return PersistedOptionMappings .Select(option => option.DatabaseKey) .Concat(["passphrase"]) .Any(opts.ContainsKey); } /// /// Verifies the parameters in the database, and updates the database if needed. /// /// The database to check. /// The options to verify. /// A cancellation token to cancel the operation. /// A task that completes when the options have been verified and the database has been updated if needed. internal static async Task VerifyOptionsAndUpdateDatabase(LocalDatabase db, Options options, CancellationToken cancellationToken) { var newDict = GetPersistedOptionValues(options); var opts = await db.GetDbOptions(cancellationToken).ConfigureAwait(false); if (options.NoEncryption) { newDict["passphrase"] = NoEncryptionSentinel; } else { string salt; opts.TryGetValue("passphrase-salt", out salt); if (string.IsNullOrEmpty(salt)) { // Not Crypto-class PRNG salts var buf = new byte[32]; new Random().NextBytes(buf); //Add version so we can detect and change the algorithm salt = "v1:" + Library.Utility.Utility.ByteArrayAsHexString(buf); } newDict["passphrase-salt"] = salt; // We avoid storing the passphrase directly, // instead we salt and rehash repeatedly newDict["passphrase"] = Library.Utility.Utility.ByteArrayAsHexString(Library.Utility.Utility.RepeatedHashWithSalt(options.Passphrase, salt, 1200)); } var needsUpdate = false; foreach (var k in newDict) if (!opts.ContainsKey(k.Key)) needsUpdate = true; else if (opts[k.Key] != k.Value) { if (k.Key == "passphrase") { if (!options.AllowPassphraseChange) { if (newDict[k.Key] == NoEncryptionSentinel) throw new Duplicati.Library.Interface.UserInformationException("You have attempted to remove the passphrase on an existing backup, which is not supported. Please configure a new clean backup if you want to remove the passphrase.", "PassphraseRemovalNotSupported"); else if (opts[k.Key] == NoEncryptionSentinel) throw new Duplicati.Library.Interface.UserInformationException("You have attempted to add a passphrase to an existing backup, which is not supported. Please configure a new clean backup if you want to add a passphrase.", "PassphraseAdditionNotSupported"); else throw new Duplicati.Library.Interface.UserInformationException("You have attempted to change a passphrase to an existing backup, which is not supported. Please configure a new clean backup if you want to change the passphrase.", "PassphraseChangeNotSupported"); } } else throw new Duplicati.Library.Interface.UserInformationException(string.Format("You have attempted to change the parameter \"{0}\" from \"{1}\" to \"{2}\", which is not supported. Please configure a new clean backup if you want to change the parameter.", k.Key, opts[k.Key], k.Value), "ParameterChangeNotSupported"); } //Extra sanity check if (await db.GetBlocksLargerThan(options.Blocksize, cancellationToken).ConfigureAwait(false) > 0) throw new Duplicati.Library.Interface.UserInformationException("You have attempted to change the block-size on an existing backup, which is not supported. Please configure a new clean backup if you want to change the block-size.", "BlockSizeChangeNotSupported"); if (needsUpdate) await PersistOptionsToDb(db, options, opts, cancellationToken).ConfigureAwait(false); } /// /// Persists the current write-path options to the local database and removes stale persisted values. /// /// The path to the local database. /// The options to persist. /// The operation description to record in the database. /// A cancellation token to cancel the operation. /// A task that completes when the options have been stored. public static async Task PersistOptionsToDatabaseWithoutValidation(string dbpath, Options options, string operation, CancellationToken cancellationToken) { await using var db = await LocalDatabase.CreateLocalDatabaseAsync(dbpath, operation, true, null, cancellationToken).ConfigureAwait(false); await PersistOptionsToDb(db, options, null, cancellationToken).ConfigureAwait(false); await db.Transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } /// /// Restores persisted write-path options from the local database into the supplied options instance. /// Explicit CLI/raw options continue to take precedence over stored values. /// /// The path to the local database. /// The options to update. /// The operation description to record in the database. /// A cancellation token to cancel the operation. /// A task that completes when the options have been restored. public static async Task UpdateOptionsFromDatabase(string dbpath, Options options, string operation, CancellationToken cancellationToken) { await using var db = await LocalDatabase.CreateLocalDatabaseAsync(dbpath, operation, true, null, cancellationToken).ConfigureAwait(false); await UpdateOptionsFromDb(db, options, cancellationToken).ConfigureAwait(false); await db.Transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } /// /// Reads the options from the given options set and returns them in a dictionary with the database keys as keys. /// /// The parsed options to translate /// A dictionary with the database keys as keys private static Dictionary GetPersistedOptionValues(Options options) { var result = new Dictionary(); foreach (var option in PersistedOptionMappings) { var value = option.GetValue(options); if (!string.IsNullOrWhiteSpace(value)) result[option.DatabaseKey] = value; } return result; } /// /// Updates the options with settings from the data, if any. /// /// The database to update /// The parsed options to update /// The existing options to merge with, or null /// A cancellation token to cancel the operation. /// An awaitable task private static async Task PersistOptionsToDb(LocalDatabase db, Options options, IDictionary existingOptions, CancellationToken cancellationToken) { existingOptions ??= await db.GetDbOptions(cancellationToken).ConfigureAwait(false); await db.SetDbOptions(BuildStoredOptions(existingOptions, options), cancellationToken).ConfigureAwait(false); } /// /// Builds a dictionary of options to store in the database, merging existing options with the new ones. /// /// The existing options to merge with /// The parsed options /// A dictionary with the database keys as keys private static Dictionary BuildStoredOptions(IDictionary existingOptions, Options options) { var result = existingOptions .Where(entry => !PersistedOptionKeys.Contains(entry.Key)) .ToDictionary(entry => entry.Key, entry => entry.Value); foreach (var entry in GetPersistedOptionValues(options)) result[entry.Key] = entry.Value; if (options.NoEncryption) { result["passphrase"] = NoEncryptionSentinel; } else { existingOptions.TryGetValue("passphrase-salt", out var salt); if (string.IsNullOrEmpty(salt)) { var buf = new byte[32]; new Random().NextBytes(buf); salt = "v1:" + Library.Utility.Utility.ByteArrayAsHexString(buf); } result["passphrase-salt"] = salt; result["passphrase"] = Library.Utility.Utility.ByteArrayAsHexString(Library.Utility.Utility.RepeatedHashWithSalt(options.Passphrase, salt, 1200)); } return result; } /// /// Restores an option from the database if it is not already set in the options. /// /// The options stored in the database /// The parsed options to update /// The database key to look for /// The option key to set /// The value to restore private static void RestoreOptionFromDb(IDictionary persistedOptions, Options options, string databaseKey, string optionKey, Func restoreValue) { if (persistedOptions.TryGetValue(databaseKey, out var value) && !string.IsNullOrWhiteSpace(value) && !HasExplicitOptionValue(options, optionKey)) options.RawOptions[optionKey] = restoreValue(value); } /// /// Checks if the given option key has an explicit value in the options. /// /// The parsed options /// The option key to check /// True if the option has an explicit value, false otherwise private static bool HasExplicitOptionValue(Options options, string optionKey) => options.RawOptions.TryGetValue(optionKey, out var value) && !string.IsNullOrWhiteSpace(value); } }