Enabled nullable for FilterExpression and Options

This adds nullability to FilterExpression and Options.
It also updates almost all properties in Options to use a common format that enforces uniform parsing of the values.

A few options have been updates to have the default values specified as a constant, to avoid replicating the value in two places.
This commit is contained in:
Kenneth Skovhede
2025-05-20 14:36:39 +02:00
parent e4689431cb
commit 1cfacca794
3 changed files with 343 additions and 963 deletions
File diff suppressed because it is too large Load Diff
+62 -64
View File
@@ -1,29 +1,32 @@
// Copyright (C) 2025, 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
// Copyright (C) 2025, 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.
#nullable enable
using System;
using System.Linq;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Globalization;
using System.Diagnostics.CodeAnalysis;
namespace Duplicati.Library.Utility
{
@@ -71,11 +74,11 @@ namespace Duplicati.Library.Utility
/// <summary>
/// The filter string
/// </summary>
public readonly string Filter;
public readonly string? Filter;
/// <summary>
/// The regular expression version of the filter
/// </summary>
public readonly Regex Regexp;
public readonly Regex? Regexp;
/// <summary>
/// The single wildcard character (DOS style)
@@ -104,7 +107,7 @@ namespace Duplicati.Library.Utility
/// Initializes a new instance of the <see cref="T:Duplicati.Library.Utility.FilterExpression.FilterEntry"/> struct.
/// </summary>
/// <param name="filter">The filter string to use.</param>
public FilterEntry(string filter)
public FilterEntry(string? filter)
{
if (string.IsNullOrEmpty(filter))
{
@@ -155,8 +158,7 @@ namespace Duplicati.Library.Utility
private static Regex GetFilterGroupRegex(string filterGroupName)
{
FilterGroup filterGroup = FilterGroups.ParseFilterList(filterGroupName, FilterGroup.None);
Regex result;
if (FilterEntry.filterGroupRegexCache.TryGetValue(filterGroup, out result))
if (FilterEntry.filterGroupRegexCache.TryGetValue(filterGroup, out var result))
{
return result;
}
@@ -290,17 +292,17 @@ namespace Duplicati.Library.Utility
case FilterType.Simple:
return string.Equals(this.Filter, path, Library.Utility.Utility.ClientFilenameStringComparison);
case FilterType.Wildcard:
return IsWildcardMatch(!Utility.IsFSCaseSensitive ? path.ToUpper(CultureInfo.InvariantCulture) : path, this.Filter);
return IsWildcardMatch(!Utility.IsFSCaseSensitive ? path.ToUpper(CultureInfo.InvariantCulture) : path, this.Filter!);
case FilterType.Regexp:
case FilterType.Group:
var m = this.Regexp.Match(path);
var m = this.Regexp!.Match(path);
return m.Success && m.Length == path.Length;
default:
return false;
}
}
public override string ToString()
public override string? ToString()
{
switch (this.Type)
{
@@ -319,7 +321,7 @@ namespace Duplicati.Library.Utility
/// <summary>
/// The internal list of expressions
/// </summary>
private readonly List<FilterEntry> m_filters;
private readonly List<FilterEntry>? m_filters;
/// <summary>
/// Gets the type of the filter
@@ -354,7 +356,7 @@ namespace Duplicati.Library.Utility
/// </summary>
/// <param name="result">The match result</param>
/// <param name="match">The filter that matched</param>
public bool Matches(string path, out bool result, out IFilter match)
public bool Matches(string path, out bool result, out IFilter? match)
{
result = false;
if (this.Type == FilterType.Empty)
@@ -363,7 +365,7 @@ namespace Duplicati.Library.Utility
return false;
}
if (m_filters.Any(x => x.Matches(path)))
if (m_filters != null && m_filters.Any(x => x.Matches(path)))
{
match = this;
result = this.Result;
@@ -385,7 +387,7 @@ namespace Duplicati.Library.Utility
/// Creates a new <see cref="FilterExpression"/> instance, representing an empty filter.
/// </summary>
public FilterExpression()
: this((IEnumerable<string>)null, true)
: this((IEnumerable<string>?)null, true)
{
}
@@ -403,7 +405,7 @@ namespace Duplicati.Library.Utility
/// </summary>
/// <param name="filter">The filter string that represents the filter</param>
/// <param name="result">Return value of <see cref="Matches(string,out bool,out IFilter)"/> in case of match</param>
public FilterExpression(IEnumerable<string> filter, bool result = true)
public FilterExpression(IEnumerable<string>? filter, bool result = true)
{
this.Result = result;
@@ -414,10 +416,9 @@ namespace Duplicati.Library.Utility
}
m_filters = Compact(
(from n in filter
let nx = new FilterEntry(n)
where nx.Type != FilterType.Empty
select nx)
filter
.Select(x => new FilterEntry(x))
.Where(x => x.Type != FilterType.Empty)
);
if (m_filters.Count == 0)
@@ -426,7 +427,7 @@ namespace Duplicati.Library.Utility
this.Type = m_filters.Max((a) => a.Type);
}
private static IEnumerable<string> Expand(string filter)
private static IEnumerable<string>? Expand(string filter)
{
if (string.IsNullOrWhiteSpace(filter))
return null;
@@ -459,7 +460,7 @@ namespace Duplicati.Library.Utility
// since that would make their names much more difficult to interpret on the command line.
if (f.Type == FilterType.Simple || f.Type == FilterType.Wildcard || f.Type == FilterType.Group)
r.Add(f);
else if (f.Type != FilterType.Empty)
else if (f.Type != FilterType.Empty && f.Regexp != null)
{
combined.Append(f.Regexp.ToString());
first = true;
@@ -518,12 +519,12 @@ namespace Duplicati.Library.Utility
/// <param name="filter">The filter to examine</param>
/// <param name="includes">True if the filter contains includes, false otherwise.</param>
/// <param name="excludes">True if the filter contains excludes, false otherwise.</param>
public static void AnalyzeFilters(IFilter filter, out bool includes, out bool excludes)
public static void AnalyzeFilters(IFilter? filter, out bool includes, out bool excludes)
{
includes = false;
excludes = false;
Tuple<bool, bool> cacheLookup = null;
Tuple<bool, bool>? cacheLookup = null;
// Check for cached results
if (filter != null)
@@ -537,7 +538,7 @@ namespace Duplicati.Library.Utility
// Figure out what components are in the filter
if (cacheLookup == null)
{
var q = new Queue<IFilter>();
var q = new Queue<IFilter?>();
q.Enqueue(filter);
while (q.Count > 0)
@@ -564,7 +565,8 @@ namespace Duplicati.Library.Utility
{
if (_matchFallbackLookup.Count > 10)
_matchFallbackLookup.Remove(_matchFallbackLookup.Keys.Skip(new Random().Next(0, _matchFallbackLookup.Count)).First());
_matchFallbackLookup[filter] = new Tuple<bool, bool>(includes, excludes);
if (filter != null)
_matchFallbackLookup[filter] = new Tuple<bool, bool>(includes, excludes);
}
}
}
@@ -575,7 +577,7 @@ namespace Duplicati.Library.Utility
/// <param name="filter">The filter to evaluate</param>
/// <param name="path">The path to evaluate</param>
/// <param name="match">The filter that matched</param>
public static bool Matches(IFilter filter, string path, out IFilter match)
public static bool Matches(IFilter filter, string path, out IFilter? match)
{
if (filter == null || filter.Empty)
{
@@ -583,14 +585,11 @@ namespace Duplicati.Library.Utility
return true;
}
bool result;
if (filter.Matches(path, out result, out match))
if (filter.Matches(path, out bool result, out match))
return result;
bool includes;
bool excludes;
AnalyzeFilters(filter, out includes, out excludes);
AnalyzeFilters(filter, out bool includes, out bool excludes);
match = null;
// We have only include filters, we exclude files by default
@@ -611,16 +610,16 @@ namespace Duplicati.Library.Utility
/// </summary>
/// <param name="first">First.</param>
/// <param name="second">Second.</param>
public static FilterExpression Combine(FilterExpression first, FilterExpression second)
public static FilterExpression? Combine(FilterExpression? first, FilterExpression? second)
{
if (first == null || first.Empty)
if (first?.m_filters == null || first.Empty)
return second;
if (second == null || second.Empty)
if (second?.m_filters == null || second.Empty)
return first;
if (first.Result != second.Result)
throw new ArgumentException("Both filters must have the same result property");
return new FilterExpression(first.m_filters.Union(second.m_filters).Select(x => x.ToString()), first.Result);
return new FilterExpression(first.m_filters.Union(second.m_filters).Select(x => x.ToString()).WhereNotNull(), first.Result);
}
/// <summary>
@@ -628,7 +627,7 @@ namespace Duplicati.Library.Utility
/// </summary>
/// <param name="first">First.</param>
/// <param name="second">Second.</param>
public static IFilter Combine(IFilter first, IFilter second)
public static IFilter? Combine(IFilter? first, IFilter? second)
{
if (second == null || second.Empty)
return first;
@@ -663,15 +662,13 @@ namespace Duplicati.Library.Utility
/// Serializes the filter instance into a list of strings
/// that can be passed to the deserialize method
/// </summary>
public string[] Serialize()
[return: NotNullIfNotNull("m_filters")]
public string[]? Serialize()
{
if (this.Empty)
if (this.Empty || m_filters == null)
return null;
return
(from n in m_filters
select $"{(this.Result ? "+" : "-")}{n.ToString()}"
).ToArray();
return m_filters.Select(n => $"{(this.Result ? "+" : "-")}{n.ToString()}").ToArray();
}
/// <summary>
@@ -692,7 +689,7 @@ namespace Duplicati.Library.Utility
var f = work.Pop();
if (f is FilterExpression expression)
res = res.Union(expression.Serialize());
res = res.Union(expression.Serialize() ?? []);
else if (f is JoinedFilterExpression filterExpression)
{
work.Push(filterExpression.Second);
@@ -710,12 +707,12 @@ namespace Duplicati.Library.Utility
/// prefixed with either minus or plus
/// </summary>
/// <param name="filters">The filters to deserialize from.</param>
public static IFilter Deserialize(string[] filters)
public static IFilter? Deserialize(string[] filters)
{
if (filters == null || filters.Length == 0)
return null;
IFilter res = null;
IFilter? res = null;
foreach (var n in filters)
{
bool include;
@@ -737,7 +734,7 @@ namespace Duplicati.Library.Utility
/// </summary>
/// <returns>The log filter.</returns>
/// <param name="value">The filter string to parse.</param>
public static IFilter ParseLogFilter(string value)
public static IFilter ParseLogFilter(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return new FilterExpression();
@@ -745,7 +742,8 @@ namespace Duplicati.Library.Utility
return value
.Split(new char[] { System.IO.Path.PathSeparator, ':', ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(StringToIFilter)
.Aggregate(FilterExpression.Combine);
.Cast<IFilter>()
.Aggregate((a, b) => FilterExpression.Combine(a, b)!);
}
/// <summary>
+26 -1
View File
@@ -789,8 +789,33 @@ namespace Duplicati.Library.Utility
/// <param name="default">default value</param>
/// <returns></returns>
public static int ParseIntOption(IReadOnlyDictionary<string, string?> options, string value, int @default)
=> options.TryGetValue(value, out var opt) && int.TryParse(opt ?? string.Empty, out var result) ? result : @default;
/// <summary>
/// Parses an option with long value, returning the default value if the option is not found or cannot be parsed
/// </summary>
/// <param name="options">The set of options to look for the setting in</param>
/// <param name="value">The value to look for in the settings</param>
/// <param name="default">default value</param>
/// <returns></returns>
public static long ParseLongOption(IReadOnlyDictionary<string, string?> options, string value, long @default)
=> options.TryGetValue(value, out var opt) && long.TryParse(opt ?? string.Empty, out var result) ? result : @default;
/// <summary>
/// Parses a size option from the option set, returning the default value if the option is not found or cannot be parsed
/// </summary>
/// <param name="options">The set of options to look for the setting in</param>
/// <param name="value">The value to look for in the settings</param>
/// <param name="defaultMultiplier">Multiplier to use if the value does not have a multiplier</param>
/// <param name="default">The default value to return if there are no matches.</param>
/// <returns>The parsed or default size value.</returns>
public static long ParseSizeOption(IReadOnlyDictionary<string, string?> options, string value, string defaultMultiplier, string @default)
{
return options.TryGetValue(value, out var opt) && int.TryParse(opt ?? string.Empty, out var result) ? result : @default;
var opt = options.GetValueOrDefault(value);
if (string.IsNullOrWhiteSpace(opt))
opt = @default;
return Sizeparser.ParseSize(opt, defaultMultiplier);
}
/// <summary>