diff --git a/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs b/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs index d2cfa871a..237ff68db 100644 --- a/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs +++ b/Duplicati/Library/Modules/Builtin/MSSQLOptions.cs @@ -21,10 +21,9 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Runtime.Versioning; -using System.Text.RegularExpressions; +using Duplicati.Library.Common.IO; using Duplicati.Library.Snapshots; using Duplicati.Library.Snapshots.Windows; @@ -40,50 +39,60 @@ namespace Duplicati.Library.Modules.Builtin /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); - private const string m_MSSQLPathDBRegExp = @"\%MSSQL\%\\(?[^\\]+\\[^\\]+)(?:\\(?[^\\]+))?\\?$"; - private const string m_MSSQLPathAllRegExp = @"%MSSQL%"; + /// + /// 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 - { - get { return "mssql-options"; } - } + public string Key => "mssql-options"; /// /// Gets the display name for this module. /// - public string DisplayName - { - get { return Strings.MSSQLOptions.DisplayName; } - } + public string DisplayName => Strings.MSSQLOptions.DisplayName; /// /// Gets the description of this module. /// - public string Description - { - get { return Strings.MSSQLOptions.Description; } - } + public string Description => Strings.MSSQLOptions.Description; /// /// Gets whether this module should be loaded by default. /// - public bool LoadAsDefault - { - get { return OperatingSystem.IsWindows(); } - } + public bool LoadAsDefault => OperatingSystem.IsWindows(); /// /// Gets the list of supported command line arguments. /// - public IList SupportedCommands - { - get { return null; } - } + public IList SupportedCommands => null; /// /// Configures the module with the provided command line options. @@ -112,13 +121,14 @@ namespace Duplicati.Library.Modules.Builtin { 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.Equals(m_MSSQLPathAllRegExp, StringComparison.OrdinalIgnoreCase) && !Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)).ToArray(); + 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 => !Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)).ToArray(); + 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); } @@ -129,6 +139,29 @@ namespace Duplicati.Library.Modules.Builtin 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). /// @@ -140,34 +173,28 @@ namespace Duplicati.Library.Modules.Builtin // referenced types from System.Management here [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] [SupportedOSPlatform("windows")] - private Dictionary RealParseSourcePaths(ref string[] paths, ref string filter, Dictionary commandlineOptions) + public Dictionary RealParseSourcePaths(ref string[] paths, ref string filter, Dictionary commandlineOptions, IMSSQLUtility mssqlUtility = null) { var changedOptions = new Dictionary(); - var filtersInclude = new List(); - var filtersExclude = new List(); + 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); - filtersInclude = filters.Where(x => x.StartsWith("+", StringComparison.Ordinal) && Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) - .Select(x => - { - var match = Regex.Match(x.Substring(1), m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return Path.Combine(match.Groups["server"].Value, match.Groups["database"].Value); - }).ToList(); - filtersExclude = filters.Where(x => x.StartsWith("-", StringComparison.Ordinal) && Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) - .Select(x => - { - var match = Regex.Match(x.Substring(1), m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return Path.Combine(match.Groups["server"].Value, match.Groups["database"].Value); - }).ToList(); + foreach (var f in filters) + { + if (f.Contains(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)) + mssqlfilters.Add(f); + else + remainingFilters.Add(f); + } - var remainingfilters = filters.Where(x => !Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)).ToArray(); - filter = string.Join(System.IO.Path.PathSeparator.ToString(), remainingfilters); + filter = string.Join(System.IO.Path.PathSeparator.ToString(), remainingFilters); } - var mssqlUtility = new MSSQLUtility(); + mssqlUtility ??= new MSSQLUtility(); if (paths == null || !ContainFilesForBackup(paths) || !mssqlUtility.IsMSSQLInstalled) return changedOptions; @@ -195,90 +222,130 @@ namespace Duplicati.Library.Modules.Builtin 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}, ID {1}, files {2}", db.Name, db.ID, string.Join(";", db.DataPaths)); + 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)); - List dbsForBackup = new List(); + var includedDbs = new List(); + var nonMatchedPaths = new List(); - if (paths.Contains(m_MSSQLPathAllRegExp, StringComparer.OrdinalIgnoreCase)) + foreach (var path in paths) { - dbsForBackup = mssqlUtility.DBs; + var match = ParsePathEntry(path); + if (match == null) + nonMatchedPaths.Add(path); + else + includedDbs.Add(match); } - else + + 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) { - var serverToDatabases = mssqlUtility.DBs.ToLookup(db => db.ID.Replace(Path.DirectorySeparatorChar + db.Name, string.Empty), db => db); - var guestEntries = paths.Where(x => Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) - .Select(x => - { - var m = Regex.Match(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return (Server: m.Groups["server"].Value, Database: m.Groups["database"].Value); - }); - - - foreach (var (server, database) in guestEntries) + // Catch-all case + if (string.IsNullOrWhiteSpace(db.Server)) { - var databases = serverToDatabases[server]; - if (string.IsNullOrWhiteSpace(database)) + 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 => { - dbsForBackup.AddRange(databases); - } - else - { - var databaseId = Path.Combine(server, database); - var foundDb = databases.FirstOrDefault(x => x.ID.Equals(databaseId, StringComparison.OrdinalIgnoreCase)); - if (foundDb is null) + // 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++) { - throw new Duplicati.Library.Interface.UserInformationException($"DB name specified in source with ID {databaseId} cannot be found", "MsSqlDatabaseNotFound"); + current += Util.AppendDirSeparator(segments[i]); + // If a parent path is filtered, exit + if (resolvedFilter.Matches(current, out var treeResult, out var _) && !treeResult) + return false; } - dbsForBackup.Add(foundDb); - } - } + // Check the actual path + if (!resolvedFilter.Matches(virtualPath, out var result, out var _)) + return !defaultExclude; + + return result; + }).ToList(); } - - if (filtersInclude.Count > 0) - foreach (var dbID in filtersInclude) - { - var foundDB = mssqlUtility.DBs.Where(x => x.ID.Equals(dbID, StringComparison.OrdinalIgnoreCase)); - - if (foundDB.Count() != 1) - throw new Duplicati.Library.Interface.UserInformationException(string.Format("DB name specified in include filter with ID {0} cannot be found", dbID), "MsSqlDatabaseNotFound"); - - dbsForBackup.Add(foundDB.First()); - Logging.Log.WriteInformationMessage(LOGTAG, "IncludeByFilter", "Including {0} based on including filters", dbID); - } - - dbsForBackup = dbsForBackup.Distinct().ToList(); - - if (filtersExclude.Count > 0) - foreach (var dbID in filtersExclude) - { - var foundDB = dbsForBackup.Where(x => x.ID.Equals(dbID, StringComparison.OrdinalIgnoreCase)); - - if (foundDB.Count() != 1) - throw new Duplicati.Library.Interface.UserInformationException(string.Format("DB name specified in exclude filter with ID {0} cannot be found", dbID), "MsSqlDatabaseNotFound"); - - dbsForBackup.Remove(foundDB.First()); - Logging.Log.WriteInformationMessage(LOGTAG, "ExcludeByFilter", "Excluding {0} based on excluding filters", dbID); - } - - var pathsForBackup = new List(paths); - var filterhandler = new Utility.FilterExpression( - filter.Split(new string[] { System.IO.Path.PathSeparator.ToString() }, StringSplitOptions.RemoveEmptyEntries).Where(x => x.StartsWith("-", StringComparison.Ordinal)).Select(x => x.Substring(1)).ToList()); - - foreach (var dbForBackup in dbsForBackup) - foreach (var pathForBackup in dbForBackup.DataPaths) - { - if (!filterhandler.Matches(pathForBackup, out _, out _)) - { - Logging.Log.WriteInformationMessage(LOGTAG, "IncludeDatabase", "For DB {0} - adding {1}", dbForBackup.Name, pathForBackup); - pathsForBackup.Add(pathForBackup); - } - else - Logging.Log.WriteInformationMessage(LOGTAG, "ExcludeByFilter", "Excluding {0} based on excluding filters", pathForBackup); - } - - paths = pathsForBackup.Where(x => !x.Equals(m_MSSQLPathAllRegExp, StringComparison.OrdinalIgnoreCase) && !Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) - .Distinct(Utility.Utility.ClientFilenameStringComparer).OrderBy(a => a).ToArray(); + var dbPaths = dbsForBackup.SelectMany(x => x.DataPaths).ToList(); + paths = nonMatchedPaths + .Concat(dbPaths) + .Distinct(Utility.Utility.ClientFilenameStringComparer) + .ToArray(); return changedOptions; } @@ -293,7 +360,8 @@ namespace Duplicati.Library.Modules.Builtin if (paths == null || !OperatingSystem.IsWindows()) return false; - return paths.Where(x => !string.IsNullOrWhiteSpace(x)).Any(x => x.Equals(m_MSSQLPathAllRegExp, StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(x, m_MSSQLPathDBRegExp, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)); + return paths.Where(x => !string.IsNullOrWhiteSpace(x)) + .Any(x => x.StartsWith(MSSQLPrefix, StringComparison.OrdinalIgnoreCase)); } #endregion diff --git a/Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs b/Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs index 6c91249f5..0f915bbb8 100644 --- a/Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs +++ b/Duplicati/Library/Snapshots/Windows/MSSQLUtility.cs @@ -26,59 +26,32 @@ using System.Runtime.Versioning; namespace Duplicati.Library.Snapshots.Windows { - public class MSSQLDB : IEquatable + /// + /// An MSSQL instance + /// + public sealed record MSSQLDB { - public string Name { get; } - public string ID { get; } - public List DataPaths { get; } + /// + /// The database name + /// + public required string Database { get; init; } + /// + /// The server name + /// + public required string Server { get; init; } + /// + /// The instance id (empty for default instance) + /// + public required string InstanceId { get; init; } + /// + /// The paths related to the database + /// + public required List DataPaths { get; init; } - public MSSQLDB(string Name, string ID, List DataPaths) - { - this.Name = Name; - this.ID = ID; - this.DataPaths = DataPaths; - } - - bool IEquatable.Equals(MSSQLDB other) - { - return ID.Equals(other.ID); - } - - public override int GetHashCode() - { - return ID.GetHashCode(); - } - - public override bool Equals(object obj) - { - MSSQLDB db = obj as MSSQLDB; - if (db != null) - { - return Equals(db); - } - else - { - return false; - } - } - - public static bool operator ==(MSSQLDB db1, MSSQLDB db2) - { - if (object.ReferenceEquals(db1, db2)) return true; - if (object.ReferenceEquals(db1, null)) return false; - if (object.ReferenceEquals(db2, null)) return false; - - return db1.Equals(db2); - } - - public static bool operator !=(MSSQLDB db1, MSSQLDB db2) - { - if (object.ReferenceEquals(db1, db2)) return false; - if (object.ReferenceEquals(db1, null)) return true; - if (object.ReferenceEquals(db2, null)) return true; - - return !db1.Equals(db2); - } + /// + /// The server instance id, e.g. "Server\Instance" or just "Server" + /// + public string ServerInstanceId => string.IsNullOrEmpty(InstanceId) ? Server : $"{Server}\\{InstanceId}"; } public interface IMSSQLUtility @@ -126,12 +99,10 @@ namespace Duplicati.Library.Snapshots.Windows /// Enumerated MS SQL DBs /// public List DBs { get { return m_DBs; } } - private readonly List m_DBs; + private readonly List m_DBs = new(); public MSSQLUtility() { - m_DBs = new List(); - if (!OperatingSystem.IsWindows()) { IsMSSQLInstalled = false; @@ -198,9 +169,22 @@ namespace Duplicati.Library.Snapshots.Windows foreach (var o in vssBackupComponents.ParseWriterMetaData(writerGUIDS)) { - m_DBs.Add(new MSSQLDB(o.Name, o.LogicalPath + "\\" + o.Name, o.Paths.ConvertAll(m => m[0].ToString().ToUpperInvariant() + m.Substring(1)) - .Distinct(Utility.Utility.ClientFilenameStringComparer) - .OrderBy(a => a).ToList())); + if (string.IsNullOrWhiteSpace(o.LogicalPath)) + continue; + + // The logical path is in the format "SERVERNAME\INSTANCENAME" or "SERVERNAME" + // We need to split it into the server name and the instance name (if any) + var pathcomp = o.LogicalPath.Split(['\\'], 2, StringSplitOptions.RemoveEmptyEntries); + m_DBs.Add(new MSSQLDB() + { + Database = o.Name, + Server = pathcomp[0], + InstanceId = pathcomp.Length > 1 ? pathcomp[1] : string.Empty, + DataPaths = o.Paths + .ConvertAll(m => m[0].ToString().ToUpperInvariant() + m.Substring(1)) + .Distinct(Utility.Utility.ClientFilenameStringComparer) + .OrderBy(a => a).ToList() + }); } } } diff --git a/Duplicati/UnitTest/TestMSSQLParser.cs b/Duplicati/UnitTest/TestMSSQLParser.cs new file mode 100644 index 000000000..816597287 --- /dev/null +++ b/Duplicati/UnitTest/TestMSSQLParser.cs @@ -0,0 +1,415 @@ +// 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 Duplicati.Library.Logging; +using Duplicati.Library.Snapshots; +using Duplicati.Library.Snapshots.Windows; +using NUnit.Framework; + +namespace Duplicati.UnitTest +{ + [TestFixture] + public class TestMSSQLParser + { + private const char PS = ';'; // Path separator between filters/sources + private const char DS = '\\'; // Directory separator + private const string MSSQL = "%MSSQL%"; + + /// + /// Mock that returns a pre-populated set of databases without touching VSS. + /// + private sealed class MockMSSQLUtility : IMSSQLUtility + { + public Guid MSSQLWriterGuid { get; set; } = Guid.Parse("a65faa63-5ea8-4ebc-9dbd-a0c4db26912a"); + public bool IsMSSQLInstalled { get; set; } = true; + public List DBs { get; set; } = new(); + + public void QueryDBsInfo(WindowsSnapshotProvider provider) + { + // No-op; DBs are injected directly by the test + } + } + + private sealed class LogSink : ILogDestination + { + public List Entries { get; } = []; + public void WriteMessage(LogEntry entry) => Entries.Add(entry); + } + + private static MSSQLDB MakeDb(string server, string instance, string database, params string[] paths) + => new MSSQLDB + { + Server = server, + InstanceId = instance, + Database = database, + DataPaths = paths.ToList() + }; + + private static Dictionary RequiredPolicyOptions() + => new(StringComparer.OrdinalIgnoreCase) { ["snapshot-policy"] = "required" }; + + private static Dictionary Run(MockMSSQLUtility util, ref string[] paths, ref string filter, Dictionary options = null) + { + options ??= RequiredPolicyOptions(); + using var _ = Log.StartScope(new LogSink()); + return new Duplicati.Library.Modules.Builtin.MSSQLOptions() + .RealParseSourcePaths(ref paths, ref filter, options, util); + } + + [Test] + public void Returns_empty_change_set_when_MSSQL_not_installed() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var util = new MockMSSQLUtility { IsMSSQLInstalled = false }; + var paths = new[] { MSSQL }; + var filter = string.Empty; + + var result = Run(util, ref paths, ref filter); + + Assert.That(result, Is.Empty); + Assert.That(paths, Is.EqualTo(new[] { MSSQL }), "Paths must stay untouched when MSSQL is not installed."); + } + + [Test] + public void All_marker_includes_every_database() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var dbInstance = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var util = new MockMSSQLUtility { DBs = [dbDefault, dbInstance] }; + + var paths = new[] { MSSQL }; + var filter = string.Empty; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbDefault.DataPaths[0])); + Assert.That(paths, Does.Contain(dbInstance.DataPaths[0])); + Assert.That(paths, Does.Not.Contain(MSSQL), "Virtual marker must be replaced by real paths."); + } + + [Test] + public void Server_path_includes_all_databases_on_server() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var dbInstance = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var dbOther = MakeDb("SRV2", "", "Other", @$"C:{DS}Data{DS}Other.mdf"); + var util = new MockMSSQLUtility { DBs = [dbDefault, dbInstance, dbOther] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1" }; + var filter = string.Empty; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbDefault.DataPaths[0]), "default-instance db on server missing"); + Assert.That(paths, Does.Contain(dbInstance.DataPaths[0]), "named-instance db on server missing"); + Assert.That(paths, Does.Not.Contain(dbOther.DataPaths[0]), "db from another server leaked in"); + } + + [Test] + public void Default_instance_database_path_resolves_single_database() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + // %MSSQL%\SRV1\Sales : 3 segments, default instance => Sales database + var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var dbHr = MakeDb("SRV1", "", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var util = new MockMSSQLUtility { DBs = [dbSales, dbHr] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}Sales" }; + var filter = string.Empty; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "selected default-instance db missing"); + Assert.That(paths, Does.Not.Contain(dbHr.DataPaths[0]), "non-selected db leaked in"); + } + + [Test] + public void Server_instance_path_includes_all_databases_in_instance() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + // %MSSQL%\SRV1\INST : 3 segments, matches a named instance => all its databases + var dbInst1 = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var dbInst2 = MakeDb("SRV1", "INST", "Payroll", @$"C:{DS}Data{DS}Payroll.mdf"); + var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var util = new MockMSSQLUtility { DBs = [dbInst1, dbInst2, dbDefault] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" }; + var filter = string.Empty; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbInst1.DataPaths[0])); + Assert.That(paths, Does.Contain(dbInst2.DataPaths[0])); + Assert.That(paths, Does.Not.Contain(dbDefault.DataPaths[0]), "default-instance db leaked in"); + } + + [Test] + public void Fully_qualified_path_resolves_single_database_on_named_instance() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + // %MSSQL%\SRV1\INST\HR : 4 segments + var dbHr = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var dbPayroll = MakeDb("SRV1", "INST", "Payroll", @$"C:{DS}Data{DS}Payroll.mdf"); + var util = new MockMSSQLUtility { DBs = [dbHr, dbPayroll] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST{DS}HR" }; + var filter = string.Empty; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbHr.DataPaths[0])); + Assert.That(paths, Does.Not.Contain(dbPayroll.DataPaths[0])); + } + + [Test] + public void Ambiguous_instance_and_default_database_name_throws() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + // A named instance "INST" exists, plus a default-instance database also called "INST". + // %MSSQL%\SRV1\INST is ambiguous (could mean the instance or the default-instance db), + // so the parser must reject it rather than silently guessing. + var dbInst = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var dbDefaultNamedInst = MakeDb("SRV1", "", "INST", @$"C:{DS}Data{DS}DefaultInst.mdf"); + var util = new MockMSSQLUtility { DBs = [dbInst, dbDefaultNamedInst] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" }; + var filter = string.Empty; + + var ex = Assert.Throws(() => + { + var p = paths; var f = filter; + Run(util, ref p, ref f); + }); + Assert.That(ex.HelpID, Is.EqualTo("MsSqlServerInstanceAmbiguous")); + } + + [Test] + public void Server_instance_match_wins_when_no_competing_default_database() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + // Only the named instance "INST" matches %MSSQL%\SRV1\INST; there is no + // default-instance database called "INST", so the instance is selected unambiguously. + var dbInst = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var util = new MockMSSQLUtility { DBs = [dbInst, dbDefault] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" }; + var filter = string.Empty; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbInst.DataPaths[0]), "instance match missing"); + Assert.That(paths, Does.Not.Contain(dbDefault.DataPaths[0]), "unrelated default-instance db leaked in"); + } + + [Test] + public void Unknown_server_throws() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var util = new MockMSSQLUtility { DBs = [MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf")] }; + var paths = new[] { $"{MSSQL}{DS}NOPE" }; + var filter = string.Empty; + + var ex = Assert.Throws(() => + { + var p = paths; var f = filter; + Run(util, ref p, ref f); + }); + Assert.That(ex.HelpID, Is.EqualTo("MsSqlServerNotFound")); + } + + [Test] + public void Unknown_instance_or_database_throws() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + // %MSSQL%\SRV1\NOPE matches neither a named instance nor a default-instance database + var util = new MockMSSQLUtility { DBs = [MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf")] }; + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}NOPE" }; + var filter = string.Empty; + + var ex = Assert.Throws(() => + { + var p = paths; var f = filter; + Run(util, ref p, ref f); + }); + Assert.That(ex.HelpID, Is.EqualTo("MsSqlServerInstanceNotFound")); + } + + [Test] + public void Unknown_database_on_named_instance_throws() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var util = new MockMSSQLUtility { DBs = [MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf")] }; + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST{DS}NOPE" }; + var filter = string.Empty; + + var ex = Assert.Throws(() => + { + var p = paths; var f = filter; + Run(util, ref p, ref f); + }); + Assert.That(ex.HelpID, Is.EqualTo("MsSqlDatabaseNotFound")); + } + + [Test] + public void Exclude_filter_removes_matching_database() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var dbHr = MakeDb("SRV1", "", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var util = new MockMSSQLUtility { DBs = [dbSales, dbHr] }; + + var paths = new[] { MSSQL }; + // Exclude HR on the default instance; the virtual path uses an empty instance segment + var filter = $"-{MSSQL}{DS}SRV1{DS}{DS}HR"; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "unaffected db missing"); + Assert.That(paths, Does.Not.Contain(dbHr.DataPaths[0]), "excluded db leaked in"); + } + + [Test] + public void Include_filter_keeps_only_matching_database() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var dbHr = MakeDb("SRV1", "", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var util = new MockMSSQLUtility { DBs = [dbSales, dbHr] }; + + var paths = new[] { MSSQL }; + // Include-only filter: anything not matched is excluded + var filter = $"+{MSSQL}{DS}SRV1{DS}{DS}Sales"; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "included db missing"); + Assert.That(paths, Does.Not.Contain(dbHr.DataPaths[0]), "non-included db leaked in"); + } + + [Test] + public void Exclude_filter_on_named_instance_database() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbHr = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf"); + var dbPayroll = MakeDb("SRV1", "INST", "Payroll", @$"C:{DS}Data{DS}Payroll.mdf"); + var util = new MockMSSQLUtility { DBs = [dbHr, dbPayroll] }; + + var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" }; + var filter = $"-{MSSQL}{DS}SRV1{DS}INST{DS}Payroll"; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbHr.DataPaths[0]), "unaffected named-instance db missing"); + Assert.That(paths, Does.Not.Contain(dbPayroll.DataPaths[0]), "excluded named-instance db leaked in"); + } + + [Test] + public void Non_MSSQL_paths_and_filters_pass_through_untouched() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var util = new MockMSSQLUtility { DBs = [dbSales] }; + + var ordinaryPath = @$"C:{DS}Users{DS}data"; + var ordinaryFilter = $"-C:{DS}Users{DS}data{DS}temp"; + var paths = new[] { MSSQL, ordinaryPath }; + var filter = ordinaryFilter; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(ordinaryPath), "ordinary source path must survive"); + Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "db path must be added"); + Assert.That(filter, Is.EqualTo(ordinaryFilter), "non-MSSQL filter must be left in the filter string"); + } + + [Test] + public void Handles_null_filter_gracefully() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var util = new MockMSSQLUtility { DBs = [dbSales] }; + + var paths = new[] { MSSQL }; + string filter = null; + + Run(util, ref paths, ref filter); + + Assert.That(paths, Does.Contain(dbSales.DataPaths[0])); + } + + [Test] + public void Enforces_required_snapshot_policy() + { + if (!OperatingSystem.IsWindows()) + return; // MSSQL is only available on Windows + + var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf"); + var util = new MockMSSQLUtility { DBs = [dbSales] }; + + var paths = new[] { MSSQL }; + var filter = string.Empty; + var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = Run(util, ref paths, ref filter, options); + + Assert.That(result.ContainsKey("snapshot-policy"), Is.True); + Assert.That(result["snapshot-policy"], Is.EqualTo("required")); + } + } +} diff --git a/Duplicati/WebserverCore/Endpoints/V1/FilesystemPlugins/MSSQL.cs b/Duplicati/WebserverCore/Endpoints/V1/FilesystemPlugins/MSSQL.cs index a57c6e12f..c37ce5cd8 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/FilesystemPlugins/MSSQL.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/FilesystemPlugins/MSSQL.cs @@ -23,8 +23,6 @@ using System.Security.Principal; using Duplicati.Library.Common.IO; using Duplicati.Library.Snapshots; using Duplicati.Library.Snapshots.Windows; -using Duplicati.WebserverCore.Dto; -using Duplicati.WebserverCore.Exceptions; namespace Duplicati.WebserverCore.Endpoints.V1.FilesystemPlugins; @@ -45,6 +43,8 @@ public class MSSQL : IFilesystemPlugin try { mssqlUtility.QueryDBsInfo(WindowsSnapshot.DEFAULT_WINDOWS_SNAPSHOT_QUERY_PROVIDER); + + // Tier 1: Root Node Selection (%MSSQL%) if (pathSegments.Length == 0) { if (mssqlUtility.DBs.Count == 0) @@ -70,9 +70,10 @@ public class MSSQL : IFilesystemPlugin ]; } + // Tier 2: Server/Instance Node Generation if (pathSegments.Length == 1) { - var serverNames = mssqlUtility.DBs.Select(x => x.ID.Replace(Path.DirectorySeparatorChar + x.Name, string.Empty)).Distinct(); + var serverNames = mssqlUtility.DBs.Select(x => x.ServerInstanceId).Distinct(); var servers = serverNames.Select(x => new Dto.TreeNodeDto { @@ -92,12 +93,15 @@ public class MSSQL : IFilesystemPlugin return servers; } - var serverToDatabases = mssqlUtility.DBs.ToLookup(db => db.ID.Replace(Path.DirectorySeparatorChar + db.Name, string.Empty), db => db); - var selectedServer = serverToDatabases[string.Join(Path.DirectorySeparatorChar, pathSegments.Skip(1))]; + // Tier 3: Database Leaf Node Generation + var serverToDatabases = mssqlUtility.DBs.ToLookup(x => x.ServerInstanceId, db => db, StringComparer.OrdinalIgnoreCase); + var targetServerKey = string.Join(Path.DirectorySeparatorChar, pathSegments.Skip(1)); + var selectedServer = serverToDatabases[targetServerKey]; + var databases = selectedServer.Select(x => new Dto.TreeNodeDto() { - text = x.Name, - id = string.Join(Path.DirectorySeparatorChar, pathSegments.Append(x.Name)), + text = x.Database, // Updated from x.Name to x.Database + id = string.Join(Path.DirectorySeparatorChar, pathSegments.Append(x.Database)), // Updated from x.Name to x.Database cls = "file", iconCls = "x-tree-icon-mssqldb", check = false, @@ -109,6 +113,7 @@ public class MSSQL : IFilesystemPlugin fileSize = -1, resolvedpath = null }).ToList(); + return databases; } catch (Exception ex)