// 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 CoCoL;
using System.Threading.Tasks;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Duplicati.Library.Main.Operation.Common;
using Duplicati.Library.Interface;
using System.Runtime.CompilerServices;
using Duplicati.Library.SourceProvider;
using Duplicati.Library.Snapshots.USN;
namespace Duplicati.Library.Main.Operation.Backup
{
///
/// The file enumeration process takes a list of source folders as input,
/// applies all filters requested and emits the filtered set of filenames
/// to its output channel
///
internal static class FileEnumerationProcess
{
///
/// The log tag to use
///
private static readonly string FILTER_LOGTAG = Logging.Log.LogTagFromType(typeof(FileEnumerationProcess));
public static Task Run(
Channels channels,
ISourceProvider sourceProvider,
UsnJournalService? journalService,
FileAttributes fileAttributeFilter,
Library.Utility.IFilter emitfilter,
Options.SymlinkStrategy symlinkPolicy,
Options.HardlinkStrategy hardlinkPolicy,
bool excludeemptyfolders,
string[]? ignorenames,
HashSet blacklistPaths,
IEnumerable? changedfilelist,
ITaskReader taskreader,
Action? onStopRequested,
CancellationToken token)
{
return AutomationExtensions.RunTask(
new
{
Output = channels.SourcePaths.AsWrite()
},
async self =>
{
if (!token.IsCancellationRequested)
{
// The hardlink map tracks the hardlink targets we have seen
// and avoid multiple processing of the same contents
var hardlinkmap = new Dictionary();
// The mixin queue is used to store symlinks that should be processed
// The symlinks are emitted during the enumeration process when they are found
var mixinqueue = new Queue();
// The enumeration filter is used to determine what paths to
// recurse into. If the emit filter only has includes,
// the enumeration filter will also include all folders,
// as nothing will match otherwise
var enumeratefilter = emitfilter;
Library.Utility.FilterExpression.AnalyzeFilters(emitfilter, out var includes, out var excludes);
if (includes && !excludes)
enumeratefilter = Library.Utility.FilterExpression.Combine(emitfilter, new Duplicati.Library.Utility.FilterExpression("*" + System.IO.Path.DirectorySeparatorChar, true))
?? new Duplicati.Library.Utility.FilterExpression();
// Simplify checking for an empty list
if (ignorenames != null && ignorenames.Length == 0)
ignorenames = null;
// Shared filter function with bound variables
ValueTask FilterEntry(ISourceProviderEntry entry)
=> SourceFileEntryFilter(entry, blacklistPaths, hardlinkPolicy, symlinkPolicy, hardlinkmap, fileAttributeFilter, enumeratefilter, ignorenames, mixinqueue, token);
// Prepare the work list
IAsyncEnumerable worklist;
// If we have a specific list, use that instead of enumerating the filesystem
if (changedfilelist != null && changedfilelist.Any())
{
async IAsyncEnumerable ExpandSources(IEnumerable list)
{
foreach (var s in list)
{
var r = await sourceProvider.GetEntry(s, s.EndsWith(Path.DirectorySeparatorChar), token).ConfigureAwait(false);
if (r != null)
{
//TODO: Set r.IsRoot = true for source elements
yield return r;
}
}
}
async IAsyncEnumerable FilterExpandedSources(IAsyncEnumerable source, [EnumeratorCancellation] CancellationToken token)
{
await foreach (var entry in source.WithCancellation(token).ConfigureAwait(false))
{
if (await FilterEntry(entry).ConfigureAwait(false))
yield return entry;
}
}
worklist = FilterExpandedSources(ExpandSources(changedfilelist), token);
}
else if (journalService != null)
{
if (!OperatingSystem.IsWindows())
throw new NotSupportedException("USN is only supported on Windows");
var fileProviders = (sourceProvider is Combiner c ? c.Providers.AsEnumerable() : [sourceProvider])
.OfType()
.ToList();
if (fileProviders.Count <= 0)
throw new InvalidOperationException("No file providers found, but USN was enabled?");
if (fileProviders.Count > 1)
throw new InvalidOperationException("Multiple file providers found, but USN only supports one");
// TODO: This is not as effecient as possible.
// If the root folder is marked changed by USN, the expansion with RecurseEntries
// will cause a full regular scan. It should be possible to *only* process the
// changed elements as returned from the USN journal.
// It should be possible to remove RecurseEntries from the GetModifiedSources()
// enumeration result.
// Such a change requires significant testing as there are many pitfalls with USN.
worklist = RecurseEntries(journalService.GetModifiedSources(FilterEntry, token),
FilterEntry,
token
)
.Concat(
RecurseEntries(journalService.GetFullScanSources(token),
FilterEntry,
token)
);
}
else
{
worklist = RecurseEntries(sourceProvider.Enumerate(token),
FilterEntry,
token
);
}
if (token.IsCancellationRequested)
return;
var source = ExpandWorkList(worklist, mixinqueue, emitfilter, enumeratefilter, token);
// TODO: There was a call to DistinctBy here, but this would cause all paths to be stored in memory
//.DistinctBy(x => x.Path, Library.Utility.Utility.IsFSCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase);
if (excludeemptyfolders)
source = ExcludeEmptyFolders(source, token);
// Process each path, and dequeue the mixins with symlinks as we go
await foreach (var s in source.WithCancellation(token).ConfigureAwait(false))
{
#if DEBUG
// For testing purposes, we need exact control
// when requesting a process stop.
// The "onStopRequested" callback is used to detect
// if the process is the real file enumeration process
// because the counter processe does not have a callback
if (onStopRequested != null)
taskreader.TestMethodCallback?.Invoke(s.Path);
#endif
// Stop if requested
if (token.IsCancellationRequested || !await taskreader.ProgressRendevouz().ConfigureAwait(false))
{
onStopRequested?.Invoke();
return;
}
await self.Output.WriteAsync(s);
}
}
});
}
///
/// A helper class to assist in excluding empty folders
///
private class DirectoryStackEntry
{
///
/// The item being tracked
///
public required ISourceProviderEntry Item;
///
/// A flag indicating if any items are found in this folder
///
public required bool AnyEntries;
}
///
/// Excludes empty folders.
///
/// The list without empty folders.
/// The list with potential empty folders.
private static async IAsyncEnumerable ExcludeEmptyFolders(IAsyncEnumerable source, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var pathstack = new Stack();
await foreach (var s in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Keep track of directories
var isDirectory = s.Path[s.Path.Length - 1] == System.IO.Path.DirectorySeparatorChar;
if (isDirectory)
{
while (pathstack.Count > 0 && !s.Path.StartsWith(pathstack.Peek().Item.Path, Library.Utility.Utility.ClientFilenameStringComparison))
{
var e = pathstack.Pop();
if (e.AnyEntries || pathstack.Count == 0)
{
// Propagate the any-flag upwards
if (pathstack.Count > 0)
pathstack.Peek().AnyEntries = true;
yield return e.Item;
}
else
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingEmptyFolder", "Excluding empty folder {0}", e.Item);
}
if (pathstack.Count == 0 || s.Path.StartsWith(pathstack.Peek().Item.Path, Library.Utility.Utility.ClientFilenameStringComparison))
{
pathstack.Push(new DirectoryStackEntry() { Item = s, AnyEntries = false });
continue;
}
}
// Just emit files
else
{
if (pathstack.Count != 0)
pathstack.Peek().AnyEntries = true;
yield return s;
}
}
while (pathstack.Count > 0)
{
var e = pathstack.Pop();
if (e.AnyEntries || pathstack.Count == 0)
{
// Propagate the any-flag upwards
if (pathstack.Count > 0)
pathstack.Peek().AnyEntries = true;
yield return e.Item;
}
}
}
///
/// Performs recursive traversal of the sources
///
/// The entries to recurse
/// The filter to apply
///
private static async IAsyncEnumerable RecurseEntries(IAsyncEnumerable entries, Func> filter, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var work = new Stack();
await foreach (var e in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
if (await filter(e).ConfigureAwait(false))
work.Push(e);
while (work.Count > 0)
{
var e = work.Pop();
// Process meta entry contents, but don't emit them for processing
if (!e.IsMetaEntry)
yield return e;
if (e.IsFolder)
{
try
{
// We only filter new items, as we assume the input is already filtered
await foreach (var r in e.Enumerate(cancellationToken).ConfigureAwait(false))
if (await filter(r).ConfigureAwait(false))
work.Push(r);
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "PathProcessingErrorEnumerate", e.Path, "Failed to enumerate path: {0}");
}
}
}
}
///
/// Re-integrates the mixin queue to form a strictly sequential list of results
///
/// The expanded list.
/// The basic enumerable.
/// The mix in queue.
/// The emitfilter.
/// The enumeratefilter.
private static async IAsyncEnumerable ExpandWorkList(IAsyncEnumerable worklist, Queue mixinqueue, Library.Utility.IFilter emitfilter, Library.Utility.IFilter? enumeratefilter, [EnumeratorCancellation] CancellationToken cancellationToken)
{
// Process each path, and dequeue the mixins with symlinks as we go
await foreach (var s in worklist.WithCancellation(cancellationToken).ConfigureAwait(false))
{
while (mixinqueue.Count > 0)
yield return mixinqueue.Dequeue();
// If there are only includes in the filter, check if the item is in the original filter
// Since the enumerate filter also includes all folders, we need to ensure we do not emit
// any entries that are filtered explicitly by the user
if (emitfilter != enumeratefilter && !Library.Utility.FilterExpression.Matches(emitfilter, s.Path, out var _))
continue;
yield return s;
}
// Trailing symlinks are caught here
while (mixinqueue.Count > 0)
yield return mixinqueue.Dequeue();
}
///
/// Performs a pre-filter on the source entry to see if it should be included in the backup
///
/// The entry to evaluate.
/// The blacklist paths.
/// True if the path should be returned, false otherwise.
private static bool PreFilterSourceEntry(ISourceProviderEntry entry, HashSet blacklistPaths)
{
// Don't filter meta stuff
if (entry.IsMetaEntry)
return true;
// Exclude any blacklisted paths
if (blacklistPaths.Contains(entry.Path))
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingBlacklistedPath", "Excluding blacklisted path: {0}", entry.Path);
return false;
}
// Exclude block devices
try
{
if (entry.IsBlockDevice)
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingBlockDevice", "Excluding block device: {0}", entry.Path);
return false;
}
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "PathProcessingErrorBlockDevice", entry.Path);
return false;
}
// Exclude character devices
try
{
if (entry.IsCharacterDevice)
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingCharacterDevice", "Excluding character device: {0}", entry.Path);
return false;
}
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "PathProcessingErrorCharacterDevice", entry.Path);
return false;
}
return true;
}
///
/// Evaluates a single entry for inclusion in the backup
///
/// The current entry.
/// The snapshot service.
/// The blacklist paths.
/// The hardlink policy.
/// The symlink policy.
/// The hardlink map.
/// The file attributes to exclude.
/// The enumerate filter.
/// The ignore names.
/// The mixin queue.
/// True if the path should be returned, false otherwise.
private static async ValueTask SourceFileEntryFilter(ISourceProviderEntry entry, HashSet blacklistPaths, Options.HardlinkStrategy hardlinkPolicy, Options.SymlinkStrategy symlinkPolicy, Dictionary hardlinkmap, FileAttributes fileAttributeFilter, Duplicati.Library.Utility.IFilter enumeratefilter, string[]? ignorenames, Queue mixinqueue, CancellationToken cancellationToken)
{
// Do the course pre-filtering first
if (!PreFilterSourceEntry(entry, blacklistPaths))
return false;
// Never exclude the root entries
if (entry.IsRootEntry)
return true;
// If we have a hardlink strategy, obey it
if (hardlinkPolicy != Options.HardlinkStrategy.All)
{
try
{
var id = entry.HardlinkTargetId;
if (id != null)
{
if (hardlinkPolicy == Options.HardlinkStrategy.None)
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingHardlinkByPolicy", "Excluding hardlink: {0} ({1})", entry.Path, id);
return false;
}
else if (hardlinkPolicy == Options.HardlinkStrategy.First)
{
if (hardlinkmap.TryGetValue(id, out var prevPath))
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingDuplicateHardlink", "Excluding hardlink ({1}) for: {0}, previous hardlink: {2}", entry.Path, id, prevPath);
return false;
}
else
{
hardlinkmap.Add(id, entry.Path);
}
}
}
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "PathProcessingErrorHardLink", entry.Path);
return false;
}
}
// Check if there is an ignore marker file
if (ignorenames != null && entry.IsFolder)
{
try
{
foreach (var n in ignorenames)
{
if (await entry.FileExists(n, cancellationToken).ConfigureAwait(false))
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathDueToIgnoreFile", "Excluding path because ignore file {0} was found in: {1}", n, entry.Path);
return false;
}
}
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "PathProcessingErrorIgnoreFile", entry.Path);
}
}
// Setup some basic processing attributes
var attributes = entry.IsFolder
? FileAttributes.Directory
: FileAttributes.Normal;
try
{
attributes = entry.Attributes;
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "PathProcessingErrorAttributes", entry.Path, "Failed to process path, using default attributes: {0}");
}
// If we exclude files based on attributes, filter that
if ((fileAttributeFilter & attributes) != 0)
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathFromAttributes", "Excluding path due to attribute filter: {0}", entry.Path);
return false;
}
// Then check if the filename is not explicitly excluded by a filter
var filtermatch = false;
if (!Library.Utility.FilterExpression.Matches(enumeratefilter, entry.Path, out var match))
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludingPathFromFilter", "Excluding path due to filter: {0} => {1}", entry.Path, match == null ? "null" : match.ToString());
return false;
}
else if (match != null)
{
filtermatch = true;
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingPathFromFilter", "Including path due to filter: {0} => {1}", entry.Path, match.ToString());
}
// If the file is a symlink, apply special handling
string? symlinkTarget = null;
try
{
symlinkTarget = entry.SymlinkTarget;
}
catch (Exception ex)
{
LogExceptionHelper.LogCommonWarning(ex, FILTER_LOGTAG, "SymlinkTargetReadError", entry.Path, "Failed to read symlink target for path: {0}");
}
if (symlinkTarget != null)
{
if (!string.IsNullOrWhiteSpace(symlinkTarget))
{
if (symlinkPolicy == Options.SymlinkStrategy.Ignore)
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "ExcludeSymlink", "Excluding symlink: {0}", entry.Path);
return false;
}
if (symlinkPolicy == Options.SymlinkStrategy.Store)
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "StoreSymlink", "Storing symlink: {0}", entry.Path);
// We return false because we do not want to recurse into the path,
// but we add the symlink to the mixin so we process the symlink itself
mixinqueue.Enqueue(entry);
return false;
}
}
else
{
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "FollowingEmptySymlink", "Treating empty symlink as regular path {0}", entry.Path);
}
}
if (!filtermatch)
Logging.Log.WriteVerboseMessage(FILTER_LOGTAG, "IncludingPath", "Including path as no filters matched: {0}", entry.Path);
// All the way through, yes!
return true;
}
}
}