// 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.Linq; using System.Runtime.Versioning; using Duplicati.Library.Common.IO; using Duplicati.Library.Snapshots; using Duplicati.Library.Snapshots.Windows; namespace Duplicati.Library.Modules.Builtin { /// /// Provides options for Microsoft SQL Server backup. /// public class MSSQLOptions : Interface.IGenericSourceModule { /// /// The tag used for logging /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The prefix used for MSSQL paths. /// private const string MSSQLPrefix = @"%MSSQL%"; /// /// A target database from a source path /// public sealed record TargetDb { /// /// The original path /// public required string Path { get; init; } /// /// The database name /// public required string Database { get; init; } /// /// The server name /// public required string Server { get; init; } /// /// The instance id /// public required string InstanceId { get; init; } } #region IGenericModule Members /// /// Gets the key identifier for this module. /// public string Key => "mssql-options"; /// /// Gets the display name for this module. /// public string DisplayName => Strings.MSSQLOptions.DisplayName; /// /// Gets the description of this module. /// public string Description => Strings.MSSQLOptions.Description; /// /// Gets whether this module should be loaded by default. /// public bool LoadAsDefault => OperatingSystem.IsWindows(); /// /// Gets the list of supported command line arguments. /// public IList SupportedCommands => null; /// /// Configures the module with the provided command line options. /// /// The command line options dictionary. public void Configure(IDictionary commandlineOptions) { // Do nothing. Implementation needed for IGenericModule interface. } #endregion #region Implementation of IGenericSourceModule /// /// Parses the source paths for Microsoft SQL Server backups. /// /// The source paths. /// The filter string. /// The command line options. /// A dictionary of changed options. public Dictionary ParseSourcePaths(ref string[] paths, ref string filter, Dictionary commandlineOptions) { // Early exit in case we are non-windows to prevent attempting to load Windows-only components if (!OperatingSystem.IsWindows()) { Logging.Log.WriteWarningMessage(LOGTAG, "MSSqlWindowsOnly", null, "Microsoft SQL Server databases backup works only on Windows OS"); // The code here is a bit defensive, but it basically removes any traces of MSSQL paths if (paths != null) paths = paths.Where(x => !x.StartsWith(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)).ToArray(); if (!string.IsNullOrEmpty(filter)) { var filters = filter.Split(new string[] { System.IO.Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries); var remainingfilters = filters.Where(x => x.Length > 1 && !x.Substring(1).StartsWith(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)).ToArray(); filter = string.Join(System.IO.Path.PathSeparator.ToString(), remainingfilters); } return new Dictionary(); } // Windows, do the real stuff! return RealParseSourcePaths(ref paths, ref filter, commandlineOptions); } private static TargetDb ParsePathEntry(string path) { if (string.IsNullOrWhiteSpace(path) || !path.StartsWith(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)) return null; var parts = path.Split(['\\'], StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 1 || !parts[0].Equals(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)) return null; return parts.Length switch { // Match all MSSQL databases 1 => new TargetDb() { Path = path, Server = "", InstanceId = "", Database = "" }, // Match a server 2 => new TargetDb() { Path = path, Server = parts[1], InstanceId = "", Database = "" }, // Match a server instance, or database on default server instance 3 => new TargetDb() { Path = path, Server = parts[1], InstanceId = parts[2], Database = "" }, // Match a database on a server instance 4 => new TargetDb() { Path = path, Server = parts[1], InstanceId = parts[2], Database = parts[3] }, _ => null }; } /// /// Parses the source paths for Microsoft SQL Server backups (real implementation). /// /// The source paths. /// The filter string. /// The command line options. /// A dictionary of changed options. // Make sure the JIT does not attempt to inline this call and thus load // referenced types from System.Management here [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] [SupportedOSPlatform("windows")] public Dictionary RealParseSourcePaths(ref string[] paths, ref string filter, Dictionary commandlineOptions, IMSSQLUtility mssqlUtility = null) { var changedOptions = new Dictionary(); var mssqlfilters = new List(); if (!string.IsNullOrEmpty(filter)) { var remainingFilters = new List(); var filters = filter.Split(new string[] { System.IO.Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries); foreach (var f in filters) { if (f.Contains(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)) mssqlfilters.Add(f); else remainingFilters.Add(f); } filter = string.Join(System.IO.Path.PathSeparator.ToString(), remainingFilters); } mssqlUtility ??= new MSSQLUtility(); if (paths == null || !ContainFilesForBackup(paths) || !mssqlUtility.IsMSSQLInstalled) return changedOptions; if (commandlineOptions.Keys.Contains("vss-exclude-writers")) { var excludedWriters = commandlineOptions["vss-exclude-writers"].Split(';').Where(x => !string.IsNullOrWhiteSpace(x) && x.Trim().Length > 0).Select(x => new Guid(x)).ToArray(); if (excludedWriters.Contains(mssqlUtility.MSSQLWriterGuid)) { Logging.Log.WriteWarningMessage(LOGTAG, "CannotExcludeMsSqlVSSWriter", null, "Excluded writers for VSS cannot contain MS SQL writer when backuping Microsoft SQL Server databases. Removing \"{0}\" to continue", mssqlUtility.MSSQLWriterGuid.ToString()); changedOptions["vss-exclude-writers"] = string.Join(";", excludedWriters.Where(x => x != mssqlUtility.MSSQLWriterGuid)); } } if (!commandlineOptions.Keys.Contains("snapshot-policy") || !commandlineOptions["snapshot-policy"].Equals("required", StringComparison.OrdinalIgnoreCase)) { Logging.Log.WriteWarningMessage(LOGTAG, "MustSetSnapshotPolicy", null, "Snapshot policy have to be set to \"required\" when backuping Microsoft SQL Server databases. Changing to \"required\" to continue"); changedOptions["snapshot-policy"] = "required"; } Logging.Log.WriteInformationMessage(LOGTAG, "StartingMsSqlQuery", "Starting to gather Microsoft SQL Server information", Logging.LogMessageType.Information); var provider = Utility.Utility.ParseEnumOption(changedOptions.AsReadOnly(), "snapshot-provider", WindowsSnapshot.DEFAULT_WINDOWS_SNAPSHOT_QUERY_PROVIDER); mssqlUtility.QueryDBsInfo(provider); Logging.Log.WriteInformationMessage(LOGTAG, "MsSqlDatabaseCount", "Found {0} databases on Microsoft SQL Server", mssqlUtility.DBs.Count); foreach (var db in mssqlUtility.DBs) Logging.Log.WriteProfilingMessage(LOGTAG, "MsSqlDatabaseName", "Found DB name {0}, Server {1}, Instance {2}, files {3}", db.Database, db.Server, db.InstanceId, string.Join(";", db.DataPaths)); var includedDbs = new List(); var nonMatchedPaths = new List(); foreach (var path in paths) { var match = ParsePathEntry(path); if (match == null) nonMatchedPaths.Add(path); else includedDbs.Add(match); } var dbsForBackup = new List(); var serverInstanceMap = mssqlUtility.DBs .GroupBy(x => x.ServerInstanceId) .ToDictionary(x => x.Key, x => x.GroupBy(y => y.Database).ToDictionary(y => y.Key, y => y.ToList(), StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase); var serverMap = mssqlUtility.DBs .GroupBy(x => x.Server) .ToDictionary(x => x.Key, x => x.GroupBy(y => y.Database).ToDictionary(y => y.Key, y => y.ToList(), StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase); foreach (var db in includedDbs) { // Catch-all case if (string.IsNullOrWhiteSpace(db.Server)) { dbsForBackup.AddRange(mssqlUtility.DBs); continue; } if (!serverMap.TryGetValue(db.Server, out var serverDbs)) throw new Duplicati.Library.Interface.UserInformationException($"Server name specified in path as \"{db.Path}\" cannot be found", "MsSqlServerNotFound"); // No instance id, so grab everything from that server if (string.IsNullOrWhiteSpace(db.InstanceId)) { dbsForBackup.AddRange(serverDbs.SelectMany(x => x.Value)); continue; } // Fully qualified name server\instance\database if (!string.IsNullOrWhiteSpace(db.Database)) { if (!serverInstanceMap.TryGetValue($"{db.Server}\\{db.InstanceId}", out var mappedServerInstance)) throw new Library.Interface.UserInformationException($"Server instance id specified in path as \"{db.Path}\" cannot be found", "MsSqlServerInstanceNotFound"); if (!mappedServerInstance.TryGetValue(db.Database, out var mappedList)) throw new Library.Interface.UserInformationException($"Database name specified in path as \"{db.Path}\" cannot be found", "MsSqlDatabaseNotFound"); dbsForBackup.AddRange(mappedList); continue; } // At this point we have a server name and one more identifier // It could be a database name or an instance id // Either server\instance or server\database, but we don't know which one it is // If we match on the instance id, grab that var matchesInstance = serverInstanceMap.TryGetValue($"{db.Server}\\{db.InstanceId}", out var serverInstanceDbs); var matchesDb = serverDbs.TryGetValue(db.InstanceId, out var dbList); if (matchesInstance && matchesDb) throw new Library.Interface.UserInformationException($"Server instance id specified in path as \"{db.Path}\" is ambiguous", "MsSqlServerInstanceAmbiguous"); if (matchesInstance) dbsForBackup.AddRange(serverInstanceDbs.SelectMany(x => x.Value)); else if (matchesDb) dbsForBackup.AddRange(dbList); else throw new Library.Interface.UserInformationException($"Server instance id specified in path as \"{db.Path}\" cannot be found", "MsSqlServerInstanceNotFound"); } // Merge duplicates that we may have picked up prior to applying the filter dbsForBackup = dbsForBackup .GroupBy(x => (x.Server, x.InstanceId, x.Database), x => x.DataPaths) .Select(x => new MSSQLDB { Server = x.Key.Server, InstanceId = x.Key.InstanceId, Database = x.Key.Database, DataPaths = x.SelectMany(y => y).Distinct(StringComparer.OrdinalIgnoreCase).ToList() }) .ToList(); if (mssqlfilters.Count > 0) { var resolvedFilter = Utility.FilterExpression.Deserialize(mssqlfilters.ToArray()); Utility.FilterExpression.AnalyzeFilters(resolvedFilter, out var filtersInclude, out var filtersExclude); var defaultExclude = filtersInclude && !filtersExclude; // true = exclude unmatched, false = include unmatched dbsForBackup = dbsForBackup .Where(x => { // We need to emulate walking the tree var virtualPath = $"{MSSQLPrefix}\\{x.ServerInstanceId}\\{x.Database}"; var segments = virtualPath.Split(['\\'], StringSplitOptions.RemoveEmptyEntries); var current = string.Empty; for (var i = 0; i < segments.Length - 1; i++) { current += Util.AppendDirSeparator(segments[i]); // If a parent path is filtered, exit if (resolvedFilter.Matches(current, out var treeResult, out var _) && !treeResult) return false; } // Check the actual path if (!resolvedFilter.Matches(virtualPath, out var result, out var _)) return !defaultExclude; return result; }).ToList(); } var dbPaths = dbsForBackup.SelectMany(x => x.DataPaths).ToList(); paths = nonMatchedPaths .Concat(dbPaths) .Distinct(Utility.Utility.ClientFilenameStringComparer) .ToArray(); return changedOptions; } /// /// Determines whether the paths contain files for Microsoft SQL Server backup. /// /// The paths to check. /// True if the paths contain Microsoft SQL Server files for backup. public bool ContainFilesForBackup(string[] paths) { if (paths == null || !OperatingSystem.IsWindows()) return false; return paths.Where(x => !string.IsNullOrWhiteSpace(x)) .Any(x => x.StartsWith(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)); } #endregion } }