From eb870e16dcf64dba2b9fe0c048e17ff60e64220d Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 22 Sep 2025 12:09:59 +0200 Subject: [PATCH 1/4] Strip null characters from path Due to a bug in .NET Core, it will sometimes return a trailing null character in the paths. Since null characters are not allowed on any OS, this PR ensures that null characters are removed before returning the path string. This fixes #6500 --- Duplicati/Library/Snapshots/SnapshotBase.cs | 50 +++++++-------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/Duplicati/Library/Snapshots/SnapshotBase.cs b/Duplicati/Library/Snapshots/SnapshotBase.cs index 0bacef4af..edc55266f 100644 --- a/Duplicati/Library/Snapshots/SnapshotBase.cs +++ b/Duplicati/Library/Snapshots/SnapshotBase.cs @@ -91,11 +91,13 @@ namespace Duplicati.Library.Snapshots { if (source.IsFolder) { + // Removing the null characters is a workaround for a bug in .NET core: + // https://github.com/dotnet/runtime/issues/49803 foreach (var f in ListFiles(source.Path)) - yield return new SnapshotSourceFileEntry(this, f, false, false); + yield return new SnapshotSourceFileEntry(this, f.Trim('\0'), false, false); foreach (var d in ListFolders(source.Path)) - yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(d), true, false); + yield return new SnapshotSourceFileEntry(this, Util.AppendDirSeparator(d.Trim('\0')), true, false); } } @@ -105,9 +107,7 @@ namespace Duplicati.Library.Snapshots /// The full path to the file in non-snapshot format /// The last write time of the file public virtual DateTime GetLastWriteTimeUtc(string localPath) - { - return File.GetLastWriteTimeUtc(localPath); - } + => File.GetLastWriteTimeUtc(localPath); /// /// Gets the last write time of a given file in UTC @@ -115,9 +115,7 @@ namespace Duplicati.Library.Snapshots /// The full path to the file in non-snapshot format /// The last write time of the file public virtual DateTime GetCreationTimeUtc(string localPath) - { - return File.GetCreationTimeUtc(localPath); - } + => File.GetCreationTimeUtc(localPath); /// /// Opens a file for reading @@ -125,9 +123,7 @@ namespace Duplicati.Library.Snapshots /// The full path to the file in non-snapshot format /// An open filestream that can be read public virtual Stream OpenRead(string localPath) - { - return File.Open(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - } + => File.Open(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); /// /// Opens a file for reading @@ -144,9 +140,7 @@ namespace Duplicati.Library.Snapshots /// The full path to the file in non-snapshot format /// The length of the file public virtual long GetFileSize(string localPath) - { - return new FileInfo(localPath).Length; - } + => new FileInfo(localPath).Length; /// /// Returns the symlink target if the entry is a symlink, and null otherwise @@ -161,9 +155,7 @@ namespace Duplicati.Library.Snapshots /// The file attributes /// The file or folder to examine public virtual FileAttributes GetAttributes(string localPath) - { - return File.GetAttributes(ConvertToSnapshotPath(localPath)); - } + => File.GetAttributes(ConvertToSnapshotPath(localPath)); /// /// Gets the metadata for the given file or folder @@ -179,9 +171,7 @@ namespace Duplicati.Library.Snapshots /// true if this instance is a block device; otherwise, false. /// The file or folder to examine public virtual bool IsBlockDevice(string localPath) - { - return false; - } + => false; /// /// Gets a unique hardlink target ID @@ -189,9 +179,7 @@ namespace Duplicati.Library.Snapshots /// The hardlink ID /// The file or folder to examine public virtual string HardlinkTargetID(string localPath) - { - return null; - } + => null; /// public abstract string ConvertToLocalPath(string snapshotPath); @@ -201,15 +189,11 @@ namespace Duplicati.Library.Snapshots /// public virtual bool FileExists(string localFilePath) - { - return File.Exists(ConvertToSnapshotPath(localFilePath)); - } + => File.Exists(ConvertToSnapshotPath(localFilePath)); /// public virtual bool DirectoryExists(string localFolderPath) - { - return Directory.Exists(ConvertToSnapshotPath(localFolderPath)); - } + => Directory.Exists(ConvertToSnapshotPath(localFolderPath)); #endregion @@ -219,9 +203,7 @@ namespace Duplicati.Library.Snapshots /// All folders found in the folder /// The folder to examinate protected virtual string[] ListFolders(string localFolderPath) - { - return Directory.GetDirectories(localFolderPath); - } + => Directory.GetDirectories(localFolderPath); /// /// Lists all files in the given folder @@ -229,9 +211,7 @@ namespace Duplicati.Library.Snapshots /// All folders found in the folder /// The folder to examinate protected virtual string[] ListFiles(string localFolderPath) - { - return Directory.GetFiles(localFolderPath); - } + => Directory.GetFiles(localFolderPath); #region IDisposable interface From 1d772127dff79bd6d72248c04aa797332f710cec Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sun, 21 Sep 2025 21:39:52 +0200 Subject: [PATCH 2/4] Fixed wrong downgrade script --- .../Scripts/Server/9. Remove ExternalID from backup.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Duplicati/CommandLine/DatabaseTool/Scripts/Server/9. Remove ExternalID from backup.sql b/Duplicati/CommandLine/DatabaseTool/Scripts/Server/9. Remove ExternalID from backup.sql index 6fba5f185..2a7d09816 100644 --- a/Duplicati/CommandLine/DatabaseTool/Scripts/Server/9. Remove ExternalID from backup.sql +++ b/Duplicati/CommandLine/DatabaseTool/Scripts/Server/9. Remove ExternalID from backup.sql @@ -6,7 +6,7 @@ CREATE TABLE "Backup" ( "Description" TEXT NOT NULL DEFAULT '', "Tags" TEXT NOT NULL, "TargetURL" TEXT NOT NULL, - "DBPath" TEXT NOT NULL, + "DBPath" TEXT NOT NULL ); INSERT INTO "Backup"("ID", "Name", "Description", "Tags", "TargetURL", "DBPath") From 5df20ea6de3ee0285d4e3f65af4ac9a0db21cb41 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 22 Sep 2025 14:36:14 +0200 Subject: [PATCH 3/4] Suppres warning in tests --- Duplicati/UnitTest/MaskingUnmaskingTest.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Duplicati/UnitTest/MaskingUnmaskingTest.cs b/Duplicati/UnitTest/MaskingUnmaskingTest.cs index d0ed433d7..b56dd9b0a 100644 --- a/Duplicati/UnitTest/MaskingUnmaskingTest.cs +++ b/Duplicati/UnitTest/MaskingUnmaskingTest.cs @@ -166,7 +166,10 @@ public class BackupConfigMaskingTests Metadata = new Dictionary() }; + // Suppress as we are in test code +#pragma warning disable SYSLIB0050 // Type or member is obsolete var conn = (Connection)FormatterServices.GetUninitializedObject(typeof(Connection)); +#pragma warning restore SYSLIB0050 // Type or member is obsolete var err = conn.ValidateBackup(backup, null); if (string.IsNullOrEmpty(err)) From b2c6ef73c5e09e3b2b3815bf3b9eddd71a6947fe Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 22 Sep 2025 14:39:46 +0200 Subject: [PATCH 4/4] Improve logging of SQL statements This updates the logging code to do invariant formatting of the SQL statements --- .../Library/Main/Database/ExtensionMethods.cs | 123 +++++------------- 1 file changed, 30 insertions(+), 93 deletions(-) diff --git a/Duplicati/Library/Main/Database/ExtensionMethods.cs b/Duplicati/Library/Main/Database/ExtensionMethods.cs index db3d3f30e..6092e0305 100644 --- a/Duplicati/Library/Main/Database/ExtensionMethods.cs +++ b/Duplicati/Library/Main/Database/ExtensionMethods.cs @@ -385,11 +385,8 @@ public static partial class ExtensionMethods /// The instance to execute on. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or -1 if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, true, null, null, -1, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, true, null, null, -1, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -398,11 +395,8 @@ public static partial class ExtensionMethods /// The default value to return if no rows are returned or the value cannot be converted. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or -1 if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, long defaultvalue, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, true, null, null, defaultvalue, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, long defaultvalue, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, true, null, null, defaultvalue, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -411,11 +405,8 @@ public static partial class ExtensionMethods /// Whether to write a log entry. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or -1 if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, bool writeLog, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, writeLog, null, null, -1, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, bool writeLog, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, writeLog, null, null, -1, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -425,11 +416,8 @@ public static partial class ExtensionMethods /// The default value to return if no rows are returned or the value cannot be converted. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or the default value if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, bool writeLog, long defaultvalue, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, writeLog, null, null, defaultvalue, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, bool writeLog, long defaultvalue, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, writeLog, null, null, defaultvalue, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -438,11 +426,8 @@ public static partial class ExtensionMethods /// The command text to execute. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or the default value if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, true, cmd, null, -1, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, true, cmd, null, -1, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -452,11 +437,8 @@ public static partial class ExtensionMethods /// The default value to return if no rows are returned or the value cannot be converted. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or the default value if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, long defaultvalue, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, true, cmd, null, defaultvalue, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, long defaultvalue, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, true, cmd, null, defaultvalue, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -466,11 +448,8 @@ public static partial class ExtensionMethods /// The values to use as parameters. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or the default value if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, Dictionary? values, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, true, cmd, values, -1, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, Dictionary? values, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, true, cmd, values, -1, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -481,11 +460,8 @@ public static partial class ExtensionMethods /// The default value to return if no rows are returned or the value cannot be converted. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or the default value if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, Dictionary? values, long defaultvalue, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, true, cmd, values, defaultvalue, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, string? cmd, Dictionary? values, long defaultvalue, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, true, cmd, values, defaultvalue, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -496,11 +472,8 @@ public static partial class ExtensionMethods /// The values to set as parameters. /// Cancellation token to cancel the operation. /// A task that when awaited contains the first column of the first row in the result set as an Int64, or the default value if no rows are returned or the value cannot be converted. - public static async Task ExecuteScalarInt64Async(this SqliteCommand self, bool writeLog, string? cmd, Dictionary? values, CancellationToken cancellationToken) - { - return await ExecuteScalarInt64Async(self, writeLog, cmd, values, -1, cancellationToken) - .ConfigureAwait(false); - } + public static Task ExecuteScalarInt64Async(this SqliteCommand self, bool writeLog, string? cmd, Dictionary? values, CancellationToken cancellationToken) + => ExecuteScalarInt64Async(self, writeLog, cmd, values, -1, cancellationToken); /// /// Executes the command asynchronously and returns the first column of the first row in the result set as an Int64. @@ -801,13 +774,13 @@ public static partial class ExtensionMethods if (p.Value == null || p.Value == DBNull.Value) v = "NULL"; else if (p.Value is string) - v = $"\"{p.Value}\""; + v = Library.Utility.Utility.FormatInvariant($"\"{p.Value}\""); else if (p.Value is DateTime dt) - v = $"\"{dt:O}\""; + v = Library.Utility.Utility.FormatInvariant($"\"{dt:O}\""); else if (p.Value is byte[] bytes) - v = $"X'{BitConverter.ToString(bytes).Replace("-", "")}'"; + v = Library.Utility.Utility.FormatInvariant($"X'{BitConverter.ToString(bytes).Replace("-", "")}'"); else - v = p.Value.ToString() ?? "NULL"; + v = p.Value?.FormatInvariant() ?? "NULL"; // Replace all occurrences of the parameter (with word boundary) txt = Regex.Replace(txt, $@"\B{Regex.Escape(paramName)}\b", v, RegexOptions.IgnoreCase); @@ -835,9 +808,7 @@ public static partial class ExtensionMethods /// The values to use as parameters. The parameters must already be added. /// The number of rows affected public static int ExecuteNonQuery(this IDbCommand self, string cmd) - { - return ExecuteNonQuery(self, true, cmd); - } + => ExecuteNonQuery(self, true, cmd); /// /// Executes the command and returns the number of rows affected. @@ -847,9 +818,7 @@ public static partial class ExtensionMethods /// The values to use as parameters. The parameters must already be added. /// The number of rows affected public static int ExecuteNonQuery(this IDbCommand self, string cmd, Dictionary values) - { - return ExecuteNonQuery(self, true, cmd, values); - } + => ExecuteNonQuery(self, true, cmd, values); /// /// Executes the command and returns the number of rows affected. @@ -906,9 +875,7 @@ public static partial class ExtensionMethods /// The command string to execute /// The scalar value of the first row public static object? ExecuteScalar(this IDbCommand self, string cmd) - { - return ExecuteScalar(self, true, cmd); - } + => ExecuteScalar(self, true, cmd); /// /// Executes the command and returns the scalar value of the first row. @@ -934,9 +901,7 @@ public static partial class ExtensionMethods /// The default value to return if no value is found /// The scalar int64 value of the first row as a string public static long ExecuteScalarInt64(this IDbCommand self, bool writeLog, long defaultvalue = -1) - { - return ExecuteScalarInt64(self, writeLog, null, defaultvalue); - } + => ExecuteScalarInt64(self, writeLog, null, defaultvalue); /// /// Executes the command and returns the scalar int64 value of the first row as a string. @@ -945,9 +910,7 @@ public static partial class ExtensionMethods /// The default value to return if no value is found /// The scalar int64 value of the first row as a string public static long ExecuteScalarInt64(this IDbCommand self, long defaultvalue = -1) - { - return ExecuteScalarInt64(self, true, null, defaultvalue); - } + => ExecuteScalarInt64(self, true, null, defaultvalue); /// /// Executes the command and returns the scalar int64 value of the first row as a string. @@ -970,9 +933,7 @@ public static partial class ExtensionMethods /// The default value to return if no value is found /// The scalar int64 value of the first row as a string public static long ExecuteScalarInt64(this IDbCommand self, string? cmd, long defaultvalue = -1) - { - return ExecuteScalarInt64(self, true, cmd, defaultvalue); - } + => ExecuteScalarInt64(self, true, cmd, defaultvalue); /// /// Executes the command and returns the scalar int64 value of the first row as a string. @@ -1003,9 +964,7 @@ public static partial class ExtensionMethods /// The values to use as parameters. The parameters must already be added. /// A instance public static IDataReader ExecuteReader(this IDbCommand self, string cmd, Dictionary? values) - { - return ExecuteReader(self, true, cmd, values); - } + => ExecuteReader(self, true, cmd, values); /// @@ -1015,9 +974,7 @@ public static partial class ExtensionMethods /// The command string to execute /// A instance public static IDataReader ExecuteReader(this IDbCommand self, string cmd) - { - return ExecuteReader(self, true, cmd); - } + => ExecuteReader(self, true, cmd); /// /// Executes the command and returns a data reader. @@ -1175,26 +1132,6 @@ public static partial class ExtensionMethods return cmd; } - /// - /// Creates the transaction for the given connection and executes a "BEGIN IMMEDIATE" command to ensure that the transaction can be comitted or rolled back. - /// This works around a quirk in SQLite where the transaction is not initialized until the first command is executed. - /// This means that if the transaction is rolled back or committed with no commands executed, it will fail with an exception. - /// - /// The connection to create the transaction on - /// The transaction - //public static IDbTransaction BeginTransactionSafe(this IDbConnection self) - //{ - // var transaction = self.BeginTransaction(); - // // using (var cmd = self.CreateCommand(transaction, "BEGIN IMMEDIATE;")) - // // try { cmd.ExecuteNonQuery(); } - // // catch { - // // ignore - // } - // - // return transaction; - //} - - /// /// Creates a command with the given command string, and adds parameters to fit the input ///