Fix MSSQL default instance handling

This PR fixes an issue introduced with a rewrite of the MSSQL logic.

The issue is caused by MSSQL having a concept of "default instance" vs "named instance". Logically, the "default instance" has a name, but it is presented differently throughout.

The code was updated to correctly handle "named instance", but this broke support for "default instance".

The paths look similar to this:
- Default instance: `%MSSQL%\Server\Database`
- Named instance: `%MSSQL%\Server\Instance\Database`

This is slightly problematic because given a path with three parts, you cannot know if it targets "default instance + database" or "named instance".

To account for this the code has been rewritten to explicitly keep track of which case we are in, and were there is ambiguity, both cases are tested. In the odd case where both match, this is treated as an error.

The filters were being applied slightly differently than the regular filters, by applying include, then exclude, and allowing include to add new elements.

This has been standardized so we now apply the filters the same way as for regular filters where "first match wins", and only source paths can introduce a new database.

Verified with both "default instance" and "named instance" on the same machine.

This fixes #6551
This fixes #6969
This commit is contained in:
Kenneth Skovhede
2026-06-22 17:26:00 +02:00
parent 26b4143689
commit 158ef33cab
4 changed files with 654 additions and 182 deletions
+186 -118
View File
@@ -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
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType<MSSQLOptions>();
private const string m_MSSQLPathDBRegExp = @"\%MSSQL\%\\(?<server>[^\\]+\\[^\\]+)(?:\\(?<database>[^\\]+))?\\?$";
private const string m_MSSQLPathAllRegExp = @"%MSSQL%";
/// <summary>
/// The prefix used for MSSQL paths.
/// </summary>
private const string MSSQLPrefix = @"%MSSQL%";
/// <summary>
/// A target database from a source path
/// </summary>
public sealed record TargetDb
{
/// <summary>
/// The original path
/// </summary>
public required string Path { get; init; }
/// <summary>
/// The database name
/// </summary>
public required string Database { get; init; }
/// <summary>
/// The server name
/// </summary>
public required string Server { get; init; }
/// <summary>
/// The instance id
/// </summary>
public required string InstanceId { get; init; }
}
#region IGenericModule Members
/// <summary>
/// Gets the key identifier for this module.
/// </summary>
public string Key
{
get { return "mssql-options"; }
}
public string Key => "mssql-options";
/// <summary>
/// Gets the display name for this module.
/// </summary>
public string DisplayName
{
get { return Strings.MSSQLOptions.DisplayName; }
}
public string DisplayName => Strings.MSSQLOptions.DisplayName;
/// <summary>
/// Gets the description of this module.
/// </summary>
public string Description
{
get { return Strings.MSSQLOptions.Description; }
}
public string Description => Strings.MSSQLOptions.Description;
/// <summary>
/// Gets whether this module should be loaded by default.
/// </summary>
public bool LoadAsDefault
{
get { return OperatingSystem.IsWindows(); }
}
public bool LoadAsDefault => OperatingSystem.IsWindows();
/// <summary>
/// Gets the list of supported command line arguments.
/// </summary>
public IList<Interface.ICommandLineArgument> SupportedCommands
{
get { return null; }
}
public IList<Interface.ICommandLineArgument> SupportedCommands => null;
/// <summary>
/// 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
};
}
/// <summary>
/// Parses the source paths for Microsoft SQL Server backups (real implementation).
/// </summary>
@@ -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<string, string> RealParseSourcePaths(ref string[] paths, ref string filter, Dictionary<string, string> commandlineOptions)
public Dictionary<string, string> RealParseSourcePaths(ref string[] paths, ref string filter, Dictionary<string, string> commandlineOptions, IMSSQLUtility mssqlUtility = null)
{
var changedOptions = new Dictionary<string, string>();
var filtersInclude = new List<string>();
var filtersExclude = new List<string>();
var mssqlfilters = new List<string>();
if (!string.IsNullOrEmpty(filter))
{
var remainingFilters = new List<string>();
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<MSSQLDB> dbsForBackup = new List<MSSQLDB>();
var includedDbs = new List<TargetDb>();
var nonMatchedPaths = new List<string>();
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<MSSQLDB>();
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<string>(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
@@ -26,59 +26,32 @@ using System.Runtime.Versioning;
namespace Duplicati.Library.Snapshots.Windows
{
public class MSSQLDB : IEquatable<MSSQLDB>
/// <summary>
/// An MSSQL instance
/// </summary>
public sealed record MSSQLDB
{
public string Name { get; }
public string ID { get; }
public List<string> DataPaths { get; }
/// <summary>
/// The database name
/// </summary>
public required string Database { get; init; }
/// <summary>
/// The server name
/// </summary>
public required string Server { get; init; }
/// <summary>
/// The instance id (empty for default instance)
/// </summary>
public required string InstanceId { get; init; }
/// <summary>
/// The paths related to the database
/// </summary>
public required List<string> DataPaths { get; init; }
public MSSQLDB(string Name, string ID, List<string> DataPaths)
{
this.Name = Name;
this.ID = ID;
this.DataPaths = DataPaths;
}
bool IEquatable<MSSQLDB>.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);
}
/// <summary>
/// The server instance id, e.g. "Server\Instance" or just "Server"
/// </summary>
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
/// </summary>
public List<MSSQLDB> DBs { get { return m_DBs; } }
private readonly List<MSSQLDB> m_DBs;
private readonly List<MSSQLDB> m_DBs = new();
public MSSQLUtility()
{
m_DBs = new List<MSSQLDB>();
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()
});
}
}
}
+415
View File
@@ -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%";
/// <summary>
/// Mock that returns a pre-populated set of databases without touching VSS.
/// </summary>
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<MSSQLDB> 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<LogEntry> 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<string, string> RequiredPolicyOptions()
=> new(StringComparer.OrdinalIgnoreCase) { ["snapshot-policy"] = "required" };
private static Dictionary<string, string> Run(MockMSSQLUtility util, ref string[] paths, ref string filter, Dictionary<string, string> 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<Duplicati.Library.Interface.UserInformationException>(() =>
{
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<Duplicati.Library.Interface.UserInformationException>(() =>
{
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<Duplicati.Library.Interface.UserInformationException>(() =>
{
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<Duplicati.Library.Interface.UserInformationException>(() =>
{
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<string, string>(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"));
}
}
}
@@ -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)