From 2a71a1e8166021d3ae77f44a9ec55cb235d95ab9 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 9 Jun 2026 10:10:03 +0200 Subject: [PATCH] Add server-side filter eval This PR adds a service-side function to perform filter evaluation. This can be used by the FE to get more correct results when using complicated filters. This fixes #6926 This fixes #4194 This fixes #3180 --- .../Dto/V2/TestFilterRequestDto.cs | 43 ++++++++ .../Dto/V2/TestFilterResponseDto.cs | 78 ++++++++++++++ .../WebserverCore/Endpoints/V2/TestFilters.cs | 102 ++++++++++++++++++ .../Services/SystemInfoProvider.cs | 1 + 4 files changed, 224 insertions(+) create mode 100644 Duplicati/WebserverCore/Dto/V2/TestFilterRequestDto.cs create mode 100644 Duplicati/WebserverCore/Dto/V2/TestFilterResponseDto.cs create mode 100644 Duplicati/WebserverCore/Endpoints/V2/TestFilters.cs diff --git a/Duplicati/WebserverCore/Dto/V2/TestFilterRequestDto.cs b/Duplicati/WebserverCore/Dto/V2/TestFilterRequestDto.cs new file mode 100644 index 000000000..df5b703d6 --- /dev/null +++ b/Duplicati/WebserverCore/Dto/V2/TestFilterRequestDto.cs @@ -0,0 +1,43 @@ +// 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. + +namespace Duplicati.WebserverCore.Dto.V2; + +/// +/// DTO for testing filesystem filters +/// +public class TestFilterRequestDto +{ + /// + /// The paths to evaluate against the filters + /// + public string[]? Paths { get; set; } + + /// + /// The source paths (roots) which are unconditionally included + /// + public string[]? Sources { get; set; } + + /// + /// The filter strings to evaluate + /// + public string[]? Filters { get; set; } +} diff --git a/Duplicati/WebserverCore/Dto/V2/TestFilterResponseDto.cs b/Duplicati/WebserverCore/Dto/V2/TestFilterResponseDto.cs new file mode 100644 index 000000000..803b7172f --- /dev/null +++ b/Duplicati/WebserverCore/Dto/V2/TestFilterResponseDto.cs @@ -0,0 +1,78 @@ +// 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. + +namespace Duplicati.WebserverCore.Dto.V2; + +/// +/// DTO for the result of testing a single filesystem path against filters +/// +public record TestFilterResponseItem +{ + /// + /// The path that was evaluated + /// + public string Path { get; set; } = ""; + + /// + /// Whether the path is included by the filters + /// + public bool Included { get; set; } + + /// + /// The string representation of the filter that matched, if any + /// + public string? MatchedFilter { get; set; } +} + +/// +/// DTO for the response of testing filesystem filters +/// +public record TestFilterResponseDto : ResponseEnvelope +{ + /// + /// Creates a success response with the test results + /// + /// The data to include + /// The response DTO + public static TestFilterResponseDto Create(TestFilterResponseItem[] data) + => new TestFilterResponseDto() + { + Success = true, + Error = null, + StatusCode = "OK", + Data = data + }; + + /// + /// Creates an error response + /// + /// The error message + /// The status code + /// The response DTO + public static TestFilterResponseDto CreateError(string error, string statusCode) + => new TestFilterResponseDto() + { + Success = false, + Error = error, + StatusCode = statusCode, + Data = null + }; +} diff --git a/Duplicati/WebserverCore/Endpoints/V2/TestFilters.cs b/Duplicati/WebserverCore/Endpoints/V2/TestFilters.cs new file mode 100644 index 000000000..b7b951291 --- /dev/null +++ b/Duplicati/WebserverCore/Endpoints/V2/TestFilters.cs @@ -0,0 +1,102 @@ +// 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 Duplicati.Library.Utility; +using Duplicati.Library.Common.IO; +using Duplicati.WebserverCore.Abstractions; +using Duplicati.WebserverCore.Dto.V2; +using Microsoft.AspNetCore.Mvc; +using Duplicati.Library.Interface; + +namespace Duplicati.WebserverCore.Endpoints.V2; + +public class TestFilters : IEndpointV2 +{ + private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/filesystem/test-filter", ([FromBody] TestFilterRequestDto request) => Execute(request)) + .RequireAuthorization(); + } + + private static TestFilterResponseDto Execute(TestFilterRequestDto request) + { + try + { + return TestFilterResponseDto.Create(Evaluate(request).ToArray()); + } + catch (Exception ex) + { + Library.Logging.Log.WriteErrorMessage(LOGTAG, "TestFilters", ex, "An error occurred while testing filters"); + return ex is UserInformationException uex + ? TestFilterResponseDto.CreateError(uex.Message, uex.HelpID) + : TestFilterResponseDto.CreateError(ex.Message, "InternalError"); + } + } + + private static IEnumerable Evaluate(TestFilterRequestDto request) + { + var filter = FilterExpression.Deserialize(request.Filters ?? Array.Empty()); + var enumeratefilter = filter; + + FilterExpression.AnalyzeFilters(filter, out var includes, out var excludes); + if (includes && !excludes) + enumeratefilter = FilterExpression.Combine(filter, new FilterExpression("*" + System.IO.Path.DirectorySeparatorChar, true)); + + var sourceArray = request.Sources ?? Array.Empty(); + var sources = new HashSet(sourceArray, Utility.ClientFilenameStringComparer); + + foreach (var path in request.Paths ?? Array.Empty()) + { + if (path == null) continue; + + // If the path is exactly one of the sources, it's always included. + if (sources.Contains(path)) + { + yield return new TestFilterResponseItem + { + Path = path, + Included = true, + MatchedFilter = null + }; + continue; + } + + // Check if the path is under any of the source folders + var isUnderSource = sourceArray + .Any(source => string.Equals(Util.AppendDirSeparator(path), Util.AppendDirSeparator(source), Utility.ClientFilenameStringComparison) || + Utility.IsPathBelowFolder(path, source)); + + // Do not return paths that are not part of the source selection + if (!isUnderSource) + continue; + + var isIncluded = FilterExpression.Matches(enumeratefilter!, path, out var match); + yield return new TestFilterResponseItem + { + Path = path, + Included = isIncluded, + MatchedFilter = match?.ToString() + }; + } + } +} diff --git a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs index 324203186..d98e99d09 100644 --- a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs +++ b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs @@ -59,6 +59,7 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec "v2:system:temp-disk-space", "v1:subscribe:remotecontrol", "v1:ipc:controller", + "v2:filesystem:test-filter" // "v1:subscribe:scheduler", ];