1087 lines
56 KiB
C#
1087 lines
56 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Main.Database.Sync;
|
|
using Duplicati.Library.Utility;
|
|
using Duplicati.Library.Main.Operation.Backup;
|
|
using Duplicati.Library.Main.Operation.Common;
|
|
using Duplicati.Library.Main.Backend;
|
|
|
|
#nullable enable
|
|
|
|
namespace Duplicati.Library.Main.Operation.Sync;
|
|
|
|
internal class SyncHandler
|
|
{
|
|
private static readonly string LOGTAG = Logging.Log.LogTagFromType<SyncHandler>();
|
|
private readonly Options m_options;
|
|
private readonly SyncResults m_results;
|
|
private readonly string[] m_sources;
|
|
private readonly string? m_backendUrl;
|
|
|
|
public SyncHandler(string[] sources, Options options, SyncResults results, string? backendUrl = null)
|
|
{
|
|
m_sources = sources;
|
|
m_options = options;
|
|
m_results = results;
|
|
m_backendUrl = backendUrl;
|
|
}
|
|
|
|
public async Task RunAsync(IBackendManager backendManager, IFilter filter)
|
|
{
|
|
var ct = m_results.TaskControl.ProgressToken;
|
|
m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Sync_Begin);
|
|
Logging.Log.WriteInformationMessage(LOGTAG, "SyncStarted", "Starting sync");
|
|
|
|
// Sync mirrors files to the destination unencrypted, so a passphrase is
|
|
// meaningless here and must never be used.
|
|
if (!m_options.NoEncryption)
|
|
throw new UserInformationException("Encryption must be disabled for sync as it is not supported.", "EncryptionNotAllowed");
|
|
|
|
var primaryDbPath = m_options.Dbpath
|
|
?? throw new InvalidOperationException("Unable to locate sync database");
|
|
|
|
var isDryRun = m_options.Dryrun;
|
|
var isDelete = m_options.SyncThenDelete;
|
|
var verifyHash = m_options.SyncVerifyHash;
|
|
var configuredStateMode = m_options.SyncRemoteState;
|
|
|
|
// --sync-recheck forces a fresh remote listing per folder for this run. Under
|
|
// UseLocalState that also means the local inventory cache is treated as stale:
|
|
// the run effectively behaves as UseRemoteState, and (because we keep the
|
|
// inventory write-through under UseLocalState) the cache is refreshed as we
|
|
// go, so the next UseLocalState run starts from a fresh baseline. Under
|
|
// UseRemoteState and BlindlyUpload the flag is a no-op (UseRemoteState already
|
|
// lists fresh; BlindlyUpload ignores remote state entirely).
|
|
var initialStateMode = (configuredStateMode == SyncRemoteState.UseLocalState && m_options.SyncRecheck)
|
|
? SyncRemoteState.UseRemoteState
|
|
: configuredStateMode;
|
|
|
|
// BlindlyUpload has no remote state to diff against, so deleting remote files
|
|
// is not meaningful. Warn once at the start so the user is not surprised that
|
|
// --sync-then-delete is ignored for the run.
|
|
if (initialStateMode == SyncRemoteState.BlindlyUpload && isDelete)
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "BlindlyUploadIgnoresDelete", null, $"sync-remote-state={SyncRemoteState.BlindlyUpload} cannot determine which remote files to delete; --sync-then-delete will be ignored for this run.");
|
|
|
|
using var sourceProvider = await SourceProviderFactory.GetSourceProviderAsync(m_sources, m_options, ct).ConfigureAwait(false);
|
|
|
|
var blacklistPaths = BackupHandler.GetBlacklistedPaths(m_options);
|
|
var fileAttributeFilter = m_options.FileAttributeFilter;
|
|
var symlinkPolicy = m_options.SymlinkPolicy;
|
|
var hardlinkPolicy = m_options.HardlinkPolicy;
|
|
var disableBackupExclusionXattr = m_options.DisableBackupExclusionXattr;
|
|
var excludeEmptyFolders = m_options.ExcludeEmptyFolders;
|
|
var ignoreNames = m_options.IgnoreFilenames;
|
|
|
|
// Start a parallel background counter that walks the whole source tree
|
|
using var counterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
Task? counterTask = null;
|
|
if (!m_options.DisableFileScanner)
|
|
{
|
|
m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Sync_CountingFiles);
|
|
counterTask = CountFilesHandler.RunAsync(
|
|
sourceProvider,
|
|
journalService: null,
|
|
m_results,
|
|
fileAttributeFilter,
|
|
filter,
|
|
symlinkPolicy,
|
|
hardlinkPolicy,
|
|
disableBackupExclusionXattr,
|
|
excludeEmptyFolders,
|
|
ignoreNames,
|
|
blacklistPaths,
|
|
counterCts.Token);
|
|
}
|
|
|
|
var planSummary = new PlanSummary();
|
|
var additionalManagers = CreateAdditionalBackendManagers();
|
|
var allManagers = new List<ManagerInstance>([
|
|
new ManagerInstance(backendManager, new LocalSyncDatabase(primaryDbPath)),
|
|
.. additionalManagers
|
|
]);
|
|
|
|
// Per-backend run state: each destination has its own database (its own inventory
|
|
// and intent journal) and its own per-run state-mode resolution (the UseLocalState
|
|
// fallback depends on whether THIS backend's inventory is empty). Computing these
|
|
// once per backend before the shared folder loop keeps the per-folder fan-out cheap
|
|
// and makes a single source enumeration feed every backend, so the same observed
|
|
// local state is replicated to all destinations instead of re-enumerating the
|
|
// source once per backend (where the source could change between enumerations).
|
|
var backendStates = new List<BackendRunState>(allManagers.Count);
|
|
try
|
|
{
|
|
// Collect all initial states from all managers
|
|
foreach (var mgr in allManagers)
|
|
{
|
|
var stateMode = initialStateMode;
|
|
var forcePopulateInventory = false;
|
|
|
|
// Resume: leftover PendingOperation rows mean a previous run was interrupted
|
|
// mid-operation. Under UseRemoteState each folder is listed fresh this run, so
|
|
// any in-flight intent is naturally superseded by the live remote state and the
|
|
// leftover rows can simply be cleared. Under UseLocalState the inventory may be
|
|
// stale for the interrupted paths; we clear the rows but warn the user that a
|
|
// recheck (UseRemoteState) is the way to reconcile fully. Under BlindlyUpload
|
|
// the journal is never consulted, so leftover rows are just noise; clear them.
|
|
if (await mgr.Database.HasAnyPendingOperationsAsync(ct).ConfigureAwait(false))
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "InflightDetected", null, "Detected incomplete operations from a previous run. Clearing the intent journal; {0}.", stateMode == SyncRemoteState.UseLocalState ? "run with --sync-recheck (or sync-remote-state=use-remote-state) to reconcile against a fresh remote listing" : "the current run will re-establish remote state per folder");
|
|
await mgr.Database.ClearPendingOperationsAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// If UseLocalState is configured but the inventory is empty (e.g. first run, or
|
|
// the database was deleted), there is no baseline to diff against. Fall back to
|
|
// UseRemoteState for this run so each folder is listed fresh; the inventory is
|
|
// populated as we go, so the next UseLocalState run has a baseline. This keeps
|
|
// the first run correct without forcing the user to pass a flag.
|
|
if (stateMode == SyncRemoteState.UseLocalState && !await mgr.Database.HasAnyInventoryAsync(ct).ConfigureAwait(false))
|
|
{
|
|
Logging.Log.WriteInformationMessage(LOGTAG, "InventoryEmpty", "Local sync inventory is empty; listing remote folders fresh for this run to establish a baseline.");
|
|
stateMode = SyncRemoteState.UseRemoteState;
|
|
forcePopulateInventory = true;
|
|
}
|
|
|
|
// Whether to write the observed state back into the inventory cache as we go.
|
|
// True under UseLocalState (the cache is the baseline and must stay current),
|
|
// under UseRemoteState + verifyHash (so computed hashes are remembered for
|
|
// later hash re-checks), and under the UseLocalState-fallback (so the empty
|
|
// cache gets seeded for the next run).
|
|
var maintainInventory = stateMode == SyncRemoteState.UseLocalState
|
|
|| (stateMode == SyncRemoteState.UseRemoteState && verifyHash)
|
|
|| forcePopulateInventory;
|
|
|
|
backendStates.Add(new BackendRunState(mgr.BackendManager, mgr.Database, stateMode, maintainInventory));
|
|
}
|
|
|
|
// Seed the folder queue with one entry per source root. The relative path of a
|
|
// source root follows the relative-path convention used throughout the handler:
|
|
// for a single source the source's contents map directly onto the backend root
|
|
// (relative root ""); for multiple sources each source contributes its own name
|
|
// as a top-level sub-folder on the remote. The local folder entry for each
|
|
// queued root is the source root itself, resolved through the source provider.
|
|
// The queue is shared across all backends: each folder is enumerated ONCE and
|
|
// the observed local children are then replicated to every backend, so the same
|
|
// source state is applied to all destinations from a single enumeration.
|
|
var folderQueue = new Queue<FolderWorkItem>();
|
|
foreach (var src in m_sources)
|
|
{
|
|
var relRoot = GetSourceRelativeRoot(src, m_sources);
|
|
var entry = await sourceProvider.GetEntryAsync(src, true, ct).ConfigureAwait(false);
|
|
if (entry == null || !entry.IsFolder)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "SourceRootMissing", null, "Source folder could not be resolved, skipping: {0}", src);
|
|
continue;
|
|
}
|
|
folderQueue.Enqueue(new FolderWorkItem(entry, relRoot));
|
|
}
|
|
|
|
m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Sync_ProcessingFiles);
|
|
while (folderQueue.Count > 0)
|
|
{
|
|
if (ct.IsCancellationRequested)
|
|
break;
|
|
|
|
var current = folderQueue.Dequeue();
|
|
await ProcessFolderAsync(
|
|
current,
|
|
backendStates,
|
|
m_results,
|
|
filter,
|
|
fileAttributeFilter,
|
|
symlinkPolicy,
|
|
hardlinkPolicy,
|
|
disableBackupExclusionXattr,
|
|
excludeEmptyFolders,
|
|
ignoreNames,
|
|
blacklistPaths,
|
|
isDryRun,
|
|
isDelete,
|
|
verifyHash,
|
|
planSummary,
|
|
folderQueue,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Stop the background counter so it does not outlive the run
|
|
await counterCts.CancelAsync().ConfigureAwait(false);
|
|
if (counterTask != null)
|
|
{
|
|
try
|
|
{
|
|
await counterTask.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected: the counter was cancelled to stop the enumeration.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "FileCountFailed", ex, "Background file count failed; progress totals may be incomplete.");
|
|
}
|
|
}
|
|
|
|
foreach (var bm in additionalManagers)
|
|
{
|
|
try
|
|
{
|
|
bm.BackendManager.Dispose();
|
|
bm.Database.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "BackendDisposeFailed", ex, "Failed to dispose additional backend manager: {0}", ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The per-folder work is committed; the remaining steps (flushing backend
|
|
// messages, the plan summary log, and the results flush) are bookkeeping
|
|
// rather than uploads, so report the wait-for-upload phase here.
|
|
m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Sync_WaitForUpload);
|
|
|
|
Logging.Log.WriteInformationMessage(LOGTAG, "SyncPlan", "Plan: Upload {0}, Update {1}, Delete {2}", planSummary.Upload, planSummary.Update, planSummary.Delete);
|
|
|
|
// Flush the running counters into the results so callers (CLI, IPC, web UI)
|
|
// see folders created, files uploaded/unchanged/deleted, and the total
|
|
// files/size encountered. Upload+Update is the combined "files uploaded"
|
|
// count; Delete maps to files deleted from the remote.
|
|
m_results.FoldersCreated = planSummary.FoldersCreated;
|
|
// The handler deletes unknown remote files only, never remote folders, so
|
|
// the folders-deleted count is always zero today; the field is exposed for
|
|
// completeness and future folder-deletion support.
|
|
m_results.FoldersDeleted = 0;
|
|
m_results.FilesUploaded = planSummary.Upload + planSummary.Update;
|
|
m_results.UnchangedFiles = planSummary.UnchangedFiles;
|
|
m_results.FilesDeleted = planSummary.Delete;
|
|
m_results.SourceFiles = planSummary.SourceFiles;
|
|
m_results.SizeOfSourceFiles = planSummary.SizeOfSourceFiles;
|
|
m_results.SizeOfUploadedFiles = planSummary.SizeOfUploadedFiles;
|
|
m_results.SizeOfDeletedFiles = planSummary.SizeOfDeletedFiles;
|
|
|
|
m_results.OperationProgressUpdater.UpdatePhase(OperationPhase.Sync_Complete);
|
|
// Final tally: the count is no longer in flux, so mark it done.
|
|
m_results.OperationProgressUpdater.UpdatefileCount(m_results.SourceFiles, m_results.SizeOfSourceFiles, true);
|
|
|
|
Logging.Log.WriteInformationMessage(LOGTAG, "SyncFinished", "Sync completed successfully.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the <see cref="Options.RemoteSyncJsonConfig" /> value and creates
|
|
/// <see cref="BackendManager" /> instances for any additional destinations.
|
|
/// In this version all options are ignored except the destination URL.
|
|
/// </summary>
|
|
private List<ManagerInstance> CreateAdditionalBackendManagers()
|
|
{
|
|
var managers = new List<ManagerInstance>();
|
|
if (string.IsNullOrWhiteSpace(m_options.RemoteSyncJsonConfig))
|
|
return managers;
|
|
|
|
string jsonContent;
|
|
var trimmed = m_options.RemoteSyncJsonConfig.TrimStart();
|
|
if (trimmed.StartsWith("{"))
|
|
{
|
|
jsonContent = m_options.RemoteSyncJsonConfig;
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
jsonContent = File.ReadAllText(m_options.RemoteSyncJsonConfig);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "RemoteSyncConfigFileReadError", ex, "Failed to read remote sync configuration file '{0}': {1}", m_options.RemoteSyncJsonConfig, ex.Message);
|
|
return managers;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
var deserializeOpts = new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower
|
|
};
|
|
var toplevel = JsonSerializer.Deserialize<TopLevelRemoteSyncConfig>(jsonContent, deserializeOpts);
|
|
if (toplevel == null)
|
|
return managers;
|
|
|
|
foreach (var dest in toplevel.Destinations)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(dest.Url) && !string.Equals(dest.Url, m_backendUrl, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try
|
|
{
|
|
|
|
var bm = new BackendManager(dest.Url, m_options, m_results.BackendWriter, m_results.TaskControl);
|
|
var db = dest.SyncDatabasePath;
|
|
if (string.IsNullOrWhiteSpace(db))
|
|
db = CLIDatabaseLocator.GetDatabasePathForCLI(dest.Url, m_options, true, false, true);
|
|
if (string.IsNullOrWhiteSpace(db))
|
|
throw new UserInformationException($"Failed to locate database for {Library.Utility.Utility.GetUrlWithoutCredentials(dest.Url)}", "DatabaseLocateFailed");
|
|
managers.Add(new ManagerInstance(bm, new LocalSyncDatabase(db)));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "AdditionalBackendCreateFailed", ex, "Failed to create backend manager for additional target {0}: {1}", Library.Utility.Utility.GetUrlWithoutCredentials(dest.Url), ex.Message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "RemoteSyncConfigParseError", ex, "Failed to parse remote sync JSON configuration: {0}", ex.Message);
|
|
}
|
|
|
|
return managers;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes a single folder across ALL backends from a single source
|
|
/// enumeration: the folder's local children are enumerated ONCE and then the
|
|
/// observed local state is replicated to every backend. Sub-folders are enqueued
|
|
/// once (the queue is shared across backends) so each folder is enumerated a
|
|
/// single time for the whole run. Only one folder's children are resident in
|
|
/// memory at a time. This guarantees the same source state is applied to every
|
|
/// destination, instead of re-enumerating the source once per backend (where the
|
|
/// source could change between enumerations and the backends would diverge).
|
|
/// </summary>
|
|
private static async Task ProcessFolderAsync(
|
|
FolderWorkItem current,
|
|
IReadOnlyList<BackendRunState> backendStates,
|
|
SyncResults results,
|
|
IFilter filter,
|
|
FileAttributes fileAttributeFilter,
|
|
Options.SymlinkStrategy symlinkPolicy,
|
|
Options.HardlinkStrategy hardlinkPolicy,
|
|
bool disableBackupExclusionXattr,
|
|
bool excludeEmptyFolders,
|
|
string[]? ignoreNames,
|
|
HashSet<string> blacklistPaths,
|
|
bool isDryRun,
|
|
bool isDelete,
|
|
bool verifyHash,
|
|
PlanSummary planSummary,
|
|
Queue<FolderWorkItem> folderQueue,
|
|
CancellationToken ct)
|
|
{
|
|
var folderRelPath = current.RelativePath;
|
|
var folderEntry = current.Entry;
|
|
|
|
// 1. Enumerate the local folder's direct children (non-recursive) ONCE and
|
|
// split them into sub-folders (to enqueue) and files (to upload this pass).
|
|
// The filter reuses the backup enumeration filter so attribute/symlink/
|
|
// hardlink/blacklist/xattr/ignore-name behavior is identical between backup
|
|
// and sync. Only this folder's children are resident in memory at once, and
|
|
// the same captured children feed every backend so the source is read a single
|
|
// time for the whole run.
|
|
var localSubfolders = new List<(ISourceProviderEntry Entry, string RelativePath)>();
|
|
var localFiles = new Dictionary<string, (ISourceProviderEntry Entry, long Size, DateTime ModifiedUtc)>(StringComparer.Ordinal);
|
|
|
|
await foreach (var child in FileEnumerationProcess.EnumerateFolderAsync(
|
|
folderEntry,
|
|
fileAttributeFilter,
|
|
filter,
|
|
symlinkPolicy,
|
|
hardlinkPolicy,
|
|
disableBackupExclusionXattr,
|
|
ignoreNames,
|
|
blacklistPaths,
|
|
ct).ConfigureAwait(false))
|
|
{
|
|
// Build the relative path on the remote destination. For the backend root
|
|
// (folderRelPath == "") the child's name is the relative path; for a nested
|
|
// folder we join the parent's relative path with the child's name using '/'.
|
|
// Validate the name so a ".." or separator-bearing name (possible on exotic
|
|
// source providers/filesystems) cannot escape the synced destination tree
|
|
// when it is later uploaded or used to compute a remote path.
|
|
var childName = GetEntryName(child.Path);
|
|
if (!TryBuildSafeRelativePath(folderRelPath, childName, out var childRelPath))
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "InvalidLocalName", null, "Skipping local source entry with an unsafe name: {0}", child.Path);
|
|
continue;
|
|
}
|
|
|
|
if (child.IsFolder)
|
|
{
|
|
localSubfolders.Add((child, childRelPath));
|
|
}
|
|
else
|
|
{
|
|
// Count every local source file encountered (regardless of whether it
|
|
// ends up uploaded, unchanged, or de-duplicated) so SourceFiles and
|
|
// SizeOfSourceFiles reflect the full local inventory scanned this run.
|
|
// The source is enumerated once for the whole run, so the count is the
|
|
// true source total regardless of how many backends receive it.
|
|
planSummary.SourceFiles++;
|
|
planSummary.SizeOfSourceFiles += Math.Max(child.Size, 0);
|
|
|
|
// INSERT OR IGNORE semantics: the first entry seen for a path wins, so a
|
|
// later duplicate in the same folder is ignored (paths are unique within
|
|
// a single folder in practice).
|
|
if (!localFiles.ContainsKey(childRelPath))
|
|
localFiles[childRelPath] = (child, child.Size, child.LastModificationUtc);
|
|
}
|
|
}
|
|
|
|
// 2. Enqueue sub-folders ONCE for the shared queue, after the (single)
|
|
// empty-folder peek. Each backend then independently ensures a sub-folder
|
|
// exists based on its OWN remote state in the per-backend fan-out below, but
|
|
// the sub-folder is processed (enumerated) only once for the whole run.
|
|
//
|
|
// ExcludeEmptyFolders: a sub-folder with no local children must not be
|
|
// created on the remote, matching the backup behavior of not creating empty
|
|
// folders at all. We peek the sub-folder's direct children FIRST and, if it
|
|
// is empty, skip it entirely (neither ensured nor queued) - this is one
|
|
// extra enumeration per sub-folder but keeps memory bounded and ensures an
|
|
// empty folder never triggers a CreateFolder call. The peek only runs when
|
|
// the option is set; without it every sub-folder is ensured/queued as before.
|
|
var enqueuedSubfolders = new List<(ISourceProviderEntry Entry, string RelativePath)>();
|
|
foreach (var (subEntry, subRelPath) in localSubfolders)
|
|
{
|
|
if (excludeEmptyFolders)
|
|
{
|
|
bool hasAny = false;
|
|
await foreach (var _ in FileEnumerationProcess.EnumerateFolderAsync(
|
|
subEntry, fileAttributeFilter, filter, symlinkPolicy, hardlinkPolicy,
|
|
disableBackupExclusionXattr, ignoreNames, blacklistPaths, ct).ConfigureAwait(false))
|
|
{
|
|
hasAny = true;
|
|
break;
|
|
}
|
|
if (!hasAny)
|
|
{
|
|
Logging.Log.WriteVerboseMessage(LOGTAG, "SkippingEmptyFolder", "Skipping empty folder: {0}", subRelPath);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
enqueuedSubfolders.Add((subEntry, subRelPath));
|
|
folderQueue.Enqueue(new FolderWorkItem(subEntry, subRelPath));
|
|
}
|
|
|
|
// 3. Replicate the captured local state to each backend. For every backend we
|
|
// resolve that backend's remote state for this folder, ensure any missing
|
|
// sub-folders, upload/update files, and (optionally) delete unknown remote
|
|
// files. The local children (localFiles / enqueuedSubfolders) are shared, so
|
|
// each backend applies the SAME observed source state; the source is not
|
|
// re-enumerated per backend, which keeps the destinations mutually consistent
|
|
// even if the source were to change mid-run.
|
|
foreach (var bs in backendStates)
|
|
{
|
|
await ProcessFolderForBackendAsync(
|
|
folderRelPath,
|
|
localFiles,
|
|
enqueuedSubfolders,
|
|
bs,
|
|
results,
|
|
isDryRun,
|
|
isDelete,
|
|
verifyHash,
|
|
planSummary,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the already-enumerated local state of a single folder to one backend:
|
|
/// resolves the backend's remote state for the folder, ensures any missing
|
|
/// sub-folders, uploads/updates the shared local files, and (optionally) deletes
|
|
/// unknown remote files. The local children are supplied by the caller (captured
|
|
/// once for the whole folder) so this method performs no source enumeration; it
|
|
/// only touches the backend and this backend's own database.
|
|
/// </summary>
|
|
private static async Task ProcessFolderForBackendAsync(
|
|
string folderRelPath,
|
|
Dictionary<string, (ISourceProviderEntry Entry, long Size, DateTime ModifiedUtc)> localFiles,
|
|
List<(ISourceProviderEntry Entry, string RelativePath)> enqueuedSubfolders,
|
|
BackendRunState bs,
|
|
SyncResults results,
|
|
bool isDryRun,
|
|
bool isDelete,
|
|
bool verifyHash,
|
|
PlanSummary planSummary,
|
|
CancellationToken ct)
|
|
{
|
|
var (backendManager, db, stateMode, maintainInventory) = bs;
|
|
|
|
// Check remote state for the current folder. Under UseRemoteState we list
|
|
// the remote folder; under UseLocalState we read the inventory cache; under
|
|
// BlindlyUpload we have no remote state. The remote state is keyed by the
|
|
// child's name (not relative path) for easy lookup against the local files,
|
|
// and a parallel set of remote sub-folder names drives the
|
|
// "create missing folders" step.
|
|
var remoteFileState = new Dictionary<string, RemoteChild>(StringComparer.Ordinal);
|
|
var remoteFolderNames = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
if (stateMode == SyncRemoteState.UseRemoteState)
|
|
{
|
|
var remoteEntries = await backendManager.ListAsync(string.IsNullOrEmpty(folderRelPath) ? null : folderRelPath, ct).ConfigureAwait(false);
|
|
foreach (var re in remoteEntries)
|
|
{
|
|
// Validate the backend-supplied name before using it. A malicious or
|
|
// compromised backend could return a name containing "..", path
|
|
// separators, or a backslash; such a name would otherwise flow into
|
|
// delete/upload/list relative paths and escape the synced destination
|
|
// tree on folder-enabled backends (e.g. the File backend joins the
|
|
// relative path onto its root). Skip the entry with a warning rather
|
|
// than carrying it through.
|
|
if (!IsValidEntryName(re.Name))
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "InvalidRemoteName", null, "Skipping remote entry with an unsafe name (contains path separators or parent-directory segments): {0}", re.Name);
|
|
continue;
|
|
}
|
|
|
|
if (re.IsFolder)
|
|
{
|
|
remoteFolderNames.Add(re.Name);
|
|
continue;
|
|
}
|
|
|
|
// Size is clamped to >= 0 because some backends return -1 to mean
|
|
// "unknown size"; the inventory stores a non-negative size and the
|
|
// diff treats -1 as "do not cross-check size". The last-modification
|
|
// time is normalized to UTC because IFileEntry.LastModification may be
|
|
// Local/Unspecified-kind and the local entry time is UTC; comparing
|
|
// DateTimes of mixed Kind compares raw ticks without conversion, which
|
|
// would treat a local noon as later than a UTC morning and skip updates.
|
|
remoteFileState[re.Name] = new RemoteChild(re.Name, Math.Max(re.Size, 0), re.LastModification.ToUniversalTime(), null);
|
|
}
|
|
|
|
// If we are maintaining the inventory (write-through under UseLocalState,
|
|
// or hash-remembering under UseRemoteState + verifyHash), reconcile the
|
|
// freshly observed listing with the cache. The listing carries no content
|
|
// hash, so for files already in the inventory we keep the stored hash and
|
|
// update only size/mtime; for files new to the cache we insert with a null
|
|
// hash (the hash will be filled in when the file is uploaded). When
|
|
// verifyHash is on we also enrich the per-file remote state with the stored
|
|
// hash so the upload-decision step can do a hash re-check. Sub-folders are
|
|
// not tracked in the inventory (it only holds file rows); folder existence
|
|
// is derived from file presence.
|
|
if (maintainInventory)
|
|
{
|
|
// Build a name->inventory-row lookup for this folder once, so the
|
|
// per-file enrichment and upsert below share the same view.
|
|
var invByName = new Dictionary<string, LocalSyncDatabase.InventoryItem>(StringComparer.Ordinal);
|
|
await foreach (var inv in db.GetInventoryItemsInFolderAsync(folderRelPath, ct).ConfigureAwait(false))
|
|
{
|
|
var invName = GetEntryName(inv.RelativePath);
|
|
if (!string.IsNullOrEmpty(invName))
|
|
invByName[invName] = inv;
|
|
}
|
|
|
|
foreach (var rc in remoteFileState.Values)
|
|
{
|
|
var relPath = string.IsNullOrEmpty(folderRelPath) ? rc.Name : folderRelPath + "/" + rc.Name;
|
|
string? storedHash = null;
|
|
if (invByName.TryGetValue(rc.Name, out var existing))
|
|
{
|
|
storedHash = existing.ContentHash;
|
|
// Preserve the stored hash: the listing has none, so upsert with
|
|
// the existing hash to avoid wiping it out.
|
|
await db.UpsertInventoryAsync(relPath, rc.Size, rc.LastModified, storedHash, ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
// New remote file not yet in the cache: insert with a null hash.
|
|
await db.UpsertInventoryAsync(relPath, rc.Size, rc.LastModified, null, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// Enrich the per-file remote state used by the upload-decision step
|
|
// so a hash re-check is possible when verifyHash is on.
|
|
if (verifyHash && !string.IsNullOrEmpty(storedHash))
|
|
remoteFileState[rc.Name] = rc with { ContentHash = storedHash };
|
|
}
|
|
}
|
|
}
|
|
else if (stateMode == SyncRemoteState.UseLocalState)
|
|
{
|
|
await foreach (var inv in db.GetInventoryItemsInFolderAsync(folderRelPath, ct).ConfigureAwait(false))
|
|
{
|
|
// The inventory only stores file rows (folders are implicit), so every
|
|
// row here is a file child of this folder. Derive the child name from
|
|
// the relative path.
|
|
var name = GetEntryName(inv.RelativePath);
|
|
if (string.IsNullOrEmpty(name))
|
|
continue;
|
|
remoteFileState[name] = new RemoteChild(name, inv.Size, inv.LastModified, inv.ContentHash);
|
|
}
|
|
}
|
|
// BlindlyUpload: no remote state. remoteFileState and remoteFolderNames stay empty.
|
|
|
|
// Create any sub-folders that are missing on THIS backend. Under UseRemoteState
|
|
// we know exactly which sub-folders already exist (from the listing); under
|
|
// UseLocalState we treat a sub-folder as existing if the inventory has any file
|
|
// under it, otherwise we ensure it; under BlindlyUpload we ensure every
|
|
// sub-folder unconditionally (the root is also ensured below). Ensuring a
|
|
// folder that already exists is a no-op on the backend (CreateFolder treats
|
|
// FolderAlreadyExisted as success) but still a network call, so we avoid it
|
|
// when we have positive evidence the folder exists. The sub-folder set is the
|
|
// shared, once-enumerated set; only the existence check is per-backend.
|
|
foreach (var (_, subRelPath) in enqueuedSubfolders)
|
|
{
|
|
var subName = GetEntryName(subRelPath);
|
|
var existsRemotely = stateMode switch
|
|
{
|
|
SyncRemoteState.UseRemoteState => remoteFolderNames.Contains(subName),
|
|
SyncRemoteState.UseLocalState => await FolderHasInventoryAsync(db, subRelPath, ct).ConfigureAwait(false),
|
|
SyncRemoteState.BlindlyUpload => false, // always ensure under blind upload
|
|
_ => false
|
|
};
|
|
|
|
if (!existsRemotely)
|
|
{
|
|
if (isDryRun)
|
|
Logging.Log.WriteDryrunMessage(LOGTAG, "DryRun", "Would create folder: {0}", subRelPath);
|
|
else
|
|
{
|
|
await backendManager.EnsureFolderAsync(subRelPath, ct).ConfigureAwait(false);
|
|
}
|
|
planSummary.FoldersCreated++;
|
|
}
|
|
}
|
|
|
|
// Under BlindlyUpload the current folder must also be ensured to exist before
|
|
// we put files into it (we have no listing to confirm it). For UseRemoteState
|
|
// and UseLocalState the folder's existence is implied: under UseRemoteState we
|
|
// just listed it (so it exists); under UseLocalState we assume the inventory
|
|
// is authoritative, and a folder containing files we're about to upload-into
|
|
// either already has files (so it exists) or is brand new, in which case the
|
|
// sub-folder ensure above created it when it was enqueued as a child. The
|
|
// backend root (folderRelPath == "") is assumed to exist for all modes.
|
|
// BlindlyUpload always treats the current folder as new (we have no listing
|
|
// to prove it exists), so it counts as a created folder when not a dry run.
|
|
if (stateMode == SyncRemoteState.BlindlyUpload && !string.IsNullOrEmpty(folderRelPath))
|
|
{
|
|
if (!isDryRun)
|
|
await backendManager.EnsureFolderAsync(folderRelPath, ct).ConfigureAwait(false);
|
|
planSummary.FoldersCreated++;
|
|
}
|
|
|
|
// Upload all new or changed files in this folder to THIS backend. The caller
|
|
// guarantees the current folder exists, so UploadOrUpdateAsync does not call
|
|
// EnsureFolderAsync. The local file entries are shared across backends; each
|
|
// backend opens its own read stream from the same entry (OpenRead is
|
|
// repeatable) and writes its own temp file / hash, so backends do not share
|
|
// mutable upload state.
|
|
foreach (var (relPath, (entry, size, modifiedUtc)) in localFiles)
|
|
{
|
|
var name = GetEntryName(relPath);
|
|
var remoteFound = remoteFileState.TryGetValue(name, out var remoteEntry);
|
|
|
|
// Decide whether an upload is needed. For BlindlyUpload we always upload.
|
|
// For UseRemoteState/UseLocalState we upload when the file is absent or
|
|
// when size/mtime differ; under verifyHash we also re-check the hash when
|
|
// size+mtime are unchanged but a remote hash exists.
|
|
var needsUpload = stateMode == SyncRemoteState.BlindlyUpload;
|
|
var hashRecheck = false;
|
|
string? remoteHash = null;
|
|
|
|
if (!needsUpload)
|
|
{
|
|
if (!remoteFound || remoteEntry == null)
|
|
{
|
|
needsUpload = true;
|
|
}
|
|
else
|
|
{
|
|
remoteHash = remoteEntry.ContentHash;
|
|
if (remoteEntry.Size != size || remoteEntry.LastModified < modifiedUtc)
|
|
{
|
|
needsUpload = true;
|
|
}
|
|
else if (verifyHash && !string.IsNullOrEmpty(remoteEntry.ContentHash)
|
|
&& remoteEntry.Size == size && remoteEntry.LastModified >= modifiedUtc)
|
|
{
|
|
// Size and mtime are unchanged but a remote hash exists: re-check
|
|
// the local hash and skip the upload if it still matches.
|
|
hashRecheck = true;
|
|
needsUpload = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!needsUpload)
|
|
{
|
|
// Verbose trace of the skip decision.
|
|
Logging.Log.WriteVerboseMessage(LOGTAG, "SyncSkipUnchanged", "Skipping unchanged file: {0} (local size={1}, mtime={2:O}; remote size={3}, mtime={4:O})", relPath, size, modifiedUtc, remoteEntry?.Size, remoteEntry?.LastModified);
|
|
planSummary.UnchangedFiles++;
|
|
// Count an unchanged file as processed too, so the processed-files
|
|
// tally advances even when no upload is performed.
|
|
results.OperationProgressUpdater.UpdatefilesProcessed(planSummary.Upload + planSummary.Update + planSummary.UnchangedFiles, planSummary.SizeOfUploadedFiles);
|
|
continue;
|
|
}
|
|
|
|
if (hashRecheck)
|
|
{
|
|
try
|
|
{
|
|
using (var s = await entry.OpenRead(ct).ConfigureAwait(false))
|
|
using (var hasher = HashFactory.CreateHasher("SHA256"))
|
|
{
|
|
var hash = Convert.ToBase64String(hasher.ComputeHash(s));
|
|
if (hash == remoteHash)
|
|
{
|
|
planSummary.UnchangedFiles++;
|
|
results.OperationProgressUpdater.UpdatefilesProcessed(planSummary.Upload + planSummary.Update + planSummary.UnchangedFiles, planSummary.SizeOfUploadedFiles);
|
|
continue; // Content unchanged; no upload needed.
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "FailedToReadFileHash", ex, "Failed to read local file hash for: {0}", relPath);
|
|
}
|
|
}
|
|
|
|
var operation = remoteFound ? SyncOperation.Update : SyncOperation.Upload;
|
|
var label = remoteFound ? "Updating" : "Uploading";
|
|
|
|
try
|
|
{
|
|
await UploadOrUpdateAsync(db, backendManager, results, relPath, entry, operation, label, verifyHash, maintainInventory, isDryRun, planSummary, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "UploadFailed", ex, "Failed to {0} file: {1}", label, relPath);
|
|
}
|
|
}
|
|
|
|
// (Optionally) delete unknown remote files in this folder on THIS backend. A
|
|
// remote file is "unknown" if no local file with the same name exists in this
|
|
// folder. Under BlindlyUpload we have no remote state, so deletes are skipped
|
|
// (the warning was logged at the start of the run). Pending-operation intent
|
|
// rows are not relevant here since the journal was cleared at the start of
|
|
// the run; a delete cannot race its own upload because the upload for this
|
|
// folder completed above before we delete.
|
|
if (isDelete && stateMode != SyncRemoteState.BlindlyUpload)
|
|
{
|
|
// Collect the paths of files actually deleted from the remote so their
|
|
// inventory removal + intent clearance can be committed in a single
|
|
// transaction at the end of the folder. Batching the per-file DB mutations
|
|
// avoids one BEGIN/COMMIT (one fsync) per deleted file; the intent record
|
|
// for each file is still written BEFORE its backend call so a crash
|
|
// mid-delete is recoverable (the leftover rows are reconciled on the next
|
|
// run). If a crash happens after some deletes succeed but before the batched
|
|
// commit, those rows remain in the inventory/intent journal and are
|
|
// reconciled against a fresh listing on resume.
|
|
var deletedPaths = new List<string>();
|
|
foreach (var (name, rc) in remoteFileState)
|
|
{
|
|
// Map the remote child name back to a relative path. A remote file is
|
|
// unknown if no local file exists at the corresponding relative path.
|
|
// Names in remoteFileState were validated at ingestion, but
|
|
// TryBuildSafeRelativePath guards defensively against ".." or separator
|
|
// segments so the delete can never escape the synced destination tree.
|
|
if (!TryBuildSafeRelativePath(folderRelPath, name, out var relPath))
|
|
{
|
|
Logging.Log.WriteWarningMessage(LOGTAG, "InvalidRemoteName", null, "Skipping delete of remote entry with an unsafe name: {0}", name);
|
|
continue;
|
|
}
|
|
if (localFiles.ContainsKey(relPath))
|
|
continue;
|
|
|
|
if (isDryRun)
|
|
{
|
|
Logging.Log.WriteDryrunMessage(LOGTAG, "DryRun", "Would delete: {0}", relPath);
|
|
planSummary.Delete++;
|
|
planSummary.SizeOfDeletedFiles += Math.Max(rc.Size, 0);
|
|
continue;
|
|
}
|
|
|
|
Logging.Log.WriteInformationMessage(LOGTAG, "Deleting", "Deleting {0}", relPath);
|
|
// Record delete intent BEFORE the backend call so a crash mid-delete is
|
|
// recoverable; the row is cleared (in the batched commit below, or on the
|
|
// next run if this one crashes before the commit) at the start of the
|
|
// next run if leftover.
|
|
await db.UpsertPendingOperationAsync(relPath, SyncOperation.Delete, null, null, ct).ConfigureAwait(false);
|
|
await backendManager.DeleteAsync(relPath, 0, true, ct).ConfigureAwait(false);
|
|
deletedPaths.Add(relPath);
|
|
planSummary.Delete++;
|
|
planSummary.SizeOfDeletedFiles += Math.Max(rc.Size, 0);
|
|
}
|
|
|
|
// Advance the processed-files counter by the files deleted in this folder
|
|
// so the UI progress reflects deletes too. Deletes do not contribute to
|
|
// the uploaded size, so only the count side moves relative to uploads.
|
|
if (planSummary.Delete > 0)
|
|
results.OperationProgressUpdater.UpdatefilesProcessed(planSummary.Upload + planSummary.Update + planSummary.UnchangedFiles + planSummary.Delete, planSummary.SizeOfUploadedFiles + planSummary.SizeOfDeletedFiles);
|
|
|
|
// Commit the inventory removals and intent clearances for all files deleted
|
|
// in this folder in one transaction, so only one fsync covers the folder.
|
|
// Under UseRemoteState (not maintaining inventory) the inventory removal is
|
|
// a no-op on an empty table but keeps the code path uniform.
|
|
if (deletedPaths.Count > 0)
|
|
{
|
|
await db.ExecuteInTransactionAsync(async txCt =>
|
|
{
|
|
foreach (var relPath in deletedPaths)
|
|
{
|
|
await db.RemoveInventoryAsync(relPath, txCt).ConfigureAwait(false);
|
|
await db.RemovePendingOperationAsync(relPath, txCt).ConfigureAwait(false);
|
|
}
|
|
}, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the inventory has any file row under the given folder, which
|
|
/// is the cheap local-database evidence that the folder exists on the remote.
|
|
/// Used under UseLocalState to avoid an EnsureFolderAsync call for folders that
|
|
/// already contain uploaded files.
|
|
/// </summary>
|
|
private static async Task<bool> FolderHasInventoryAsync(LocalSyncDatabase db, string folderRelPath, CancellationToken ct)
|
|
{
|
|
await foreach (var _ in db.GetInventoryItemsInFolderAsync(folderRelPath, ct).ConfigureAwait(false))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uploads (or updates) a single local file to the remote destination, recording
|
|
/// intent before the upload and committing the inventory update + intent clearance
|
|
/// atomically after it. The caller MUST have ensured the destination folder exists
|
|
/// already; this method does not call <see cref="IBackendManager.EnsureFolderAsync"/>.
|
|
/// Shared by the upload and update paths so the two cannot drift.
|
|
/// </summary>
|
|
/// <param name="db">The sync database.</param>
|
|
/// <param name="backendManager">The backend manager used to perform the upload.</param>
|
|
/// <param name="results">The sync results whose progress updater receives per-file progress.</param>
|
|
/// <param name="relPath">The relative path on the remote destination.</param>
|
|
/// <param name="lf">The local source entry to upload.</param>
|
|
/// <param name="operation">The intent operation to record (<see cref="SyncOperation.Upload"/> or <see cref="SyncOperation.Update"/>).</param>
|
|
/// <param name="logLabel">The label used in the log message (e.g. "Uploading" or "Updating").</param>
|
|
/// <param name="verifyHash">Whether to compute and store a content hash.</param>
|
|
/// <param name="maintainInventory">Whether to write the observed state back into the inventory cache (UseLocalState). Under UseRemoteState/BlindlyUpload the inventory is not maintained.</param>
|
|
/// <param name="isDryRun">Whether this is a dry run; when true the upload is logged but not performed.</param>
|
|
/// <param name="planSummary">The running plan counters to bump for the summary log.</param>
|
|
/// <param name="cancellationToken">Cancellation token to monitor for cancellation requests.</param>
|
|
private static async Task UploadOrUpdateAsync(
|
|
LocalSyncDatabase db,
|
|
IBackendManager backendManager,
|
|
SyncResults results,
|
|
string relPath,
|
|
ISourceProviderEntry lf,
|
|
SyncOperation operation,
|
|
string logLabel,
|
|
bool verifyHash,
|
|
bool maintainInventory,
|
|
bool isDryRun,
|
|
PlanSummary planSummary,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (isDryRun)
|
|
{
|
|
Logging.Log.WriteDryrunMessage(LOGTAG, "DryRun", "Would {0}: {1}", logLabel.ToLowerInvariant(), relPath);
|
|
if (operation == SyncOperation.Upload)
|
|
planSummary.Upload++;
|
|
else
|
|
planSummary.Update++;
|
|
planSummary.SizeOfUploadedFiles += Math.Max(lf.Size, 0);
|
|
return;
|
|
}
|
|
|
|
Logging.Log.WriteInformationMessage(LOGTAG, logLabel, "{0} {1}", logLabel, relPath);
|
|
|
|
// Tell the progress reporter we are starting on this file so the UI can show
|
|
// the current filename and the total size we expect to write for it. The
|
|
// matching UpdatefilesProcessed below advances the processed count by one
|
|
// file and its size once the upload (or its failure) is accounted for.
|
|
results.OperationProgressUpdater.StartFile(relPath, Math.Max(lf.Size, 0));
|
|
|
|
// Record intent BEFORE the upload so a crash during the put is recoverable.
|
|
// This row is cleared at the start of the next run (the journal is reconciled
|
|
// per folder against a fresh listing under UseRemoteState, or cleared outright).
|
|
await db.UpsertPendingOperationAsync(relPath, operation, lf.Size, null, cancellationToken);
|
|
|
|
using var temp = new TempFile();
|
|
using (var s = await lf.OpenRead(cancellationToken))
|
|
using (var fs = System.IO.File.OpenWrite(temp))
|
|
await s.CopyToAsync(fs, cancellationToken);
|
|
|
|
string? hash = null;
|
|
if (verifyHash)
|
|
{
|
|
using var hashStream = System.IO.File.OpenRead(temp);
|
|
using var hasher = HashFactory.CreateHasher("SHA256");
|
|
hash = Convert.ToBase64String(hasher.ComputeHash(hashStream));
|
|
}
|
|
|
|
// The caller has already ensured the destination folder exists, so we put the
|
|
// file directly. No EnsureFolderAsync call here.
|
|
await backendManager.PutFileUnencryptedAsync(relPath, temp, cancellationToken);
|
|
|
|
// Commit the inventory update and intent clearance atomically so a crash
|
|
// between them cannot leave the inventory and intent journal inconsistent.
|
|
// Under UseRemoteState/BlindlyUpload (maintainInventory == false) the inventory
|
|
// upsert is skipped; the intent clearance still runs so the journal stays clean.
|
|
await db.ExecuteInTransactionAsync(async txCt =>
|
|
{
|
|
if (maintainInventory)
|
|
await db.UpsertInventoryAsync(relPath, lf.Size, lf.LastModificationUtc, hash, txCt);
|
|
await db.RemovePendingOperationAsync(relPath, txCt);
|
|
}, cancellationToken);
|
|
|
|
if (operation == SyncOperation.Upload)
|
|
planSummary.Upload++;
|
|
else
|
|
planSummary.Update++;
|
|
planSummary.SizeOfUploadedFiles += Math.Max(lf.Size, 0);
|
|
|
|
// The file has been fully processed (uploaded or its attempt is accounted for),
|
|
// so advance the processed-files counter by one file and its size. This pairs
|
|
// with the StartFile call above and lets the UI show N of M files processed.
|
|
results.OperationProgressUpdater.UpdatefilesProcessed(planSummary.Upload + planSummary.Update + planSummary.UnchangedFiles, planSummary.SizeOfUploadedFiles);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the relative root path on the remote destination for a source root,
|
|
/// matching the relative-path convention used throughout the handler. For a
|
|
/// single source the source's contents map directly onto the backend root
|
|
/// (relative root ""); for multiple sources each source contributes its own name
|
|
/// as a top-level sub-folder.
|
|
/// </summary>
|
|
private static string GetSourceRelativeRoot(string sourcePath, string[] sources)
|
|
{
|
|
if (sources.Length == 1)
|
|
return "";
|
|
|
|
var normalized = sourcePath.Replace('\\', '/').TrimEnd('/');
|
|
var sourceName = Path.GetFileName(normalized);
|
|
return sourceName ?? "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the entry name (the last path component) from a source-provider entry
|
|
/// path, normalizing backslashes to forward slashes and stripping any trailing
|
|
/// separators first (folder entries from the snapshot service carry a trailing
|
|
/// directory separator, which would otherwise yield an empty name). Returns the
|
|
/// empty string if the path has no name component.
|
|
/// </summary>
|
|
private static string GetEntryName(string entryPath)
|
|
{
|
|
var normalized = entryPath.Replace('\\', '/').TrimEnd('/');
|
|
var idx = normalized.LastIndexOf('/');
|
|
return idx < 0 ? normalized : normalized.Substring(idx + 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that a single entry name (a remote or local child name, with no
|
|
/// path separators) is safe to use as a component of a relative path on the
|
|
/// remote destination. Rejects names that could escape the synced tree or break
|
|
/// the path bookkeeping: parent-directory segments (<c>..</c>), empty names,
|
|
/// names containing path separators (the relative path is built by joining with
|
|
/// <c>/</c>, so a separator in a name would silently inject a sub-folder), and
|
|
/// names containing backslashes (which a Windows backend may treat as a
|
|
/// separator). Backends are no longer expected to create missing parent folders
|
|
/// and folder-enabled backends (e.g. the File backend) join the relative path
|
|
/// onto their root, so a <c>..</c> or absolute name from a malicious/compromised
|
|
/// backend or a malformed local name could otherwise delete or upload outside
|
|
/// the configured destination. Returns <c>true</c> when the name is safe.
|
|
/// </summary>
|
|
/// <param name="name">The entry name to validate.</param>
|
|
/// <returns><c>true</c> when the name is a safe single path component; <c>false</c> otherwise.</returns>
|
|
private static bool IsValidEntryName(string? name)
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
return false;
|
|
if (name == "." || name == "..")
|
|
return false;
|
|
if (name!.Contains('/') || name.Contains('\\'))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that a relative path built from a folder's relative path and a
|
|
/// child entry name is safe to use on the remote destination. The folder
|
|
/// relative path is itself validated for traversal segments, and the child name
|
|
/// is validated via <see cref="IsValidEntryName"/>. Returns <c>true</c> when the
|
|
/// resulting relative path is safe; <c>false</c> otherwise (and the caller should
|
|
/// skip the entry with a warning rather than carry it through to the backend).
|
|
/// </summary>
|
|
/// <param name="folderRelPath">The folder's relative path (may be empty for the backend root).</param>
|
|
/// <param name="childName">The child entry name to append.</param>
|
|
/// <param name="relPath">When safe, the joined relative path; otherwise null.</param>
|
|
/// <returns><c>true</c> when the joined path is safe; <c>false</c> otherwise.</returns>
|
|
private static bool TryBuildSafeRelativePath(string folderRelPath, string? childName, out string relPath)
|
|
{
|
|
relPath = string.Empty;
|
|
if (!IsValidEntryName(childName))
|
|
return false;
|
|
// The folder relative path is constructed by this handler from previously
|
|
// validated names, but validate defensively in case a future caller supplies
|
|
// an unvalidated folder path.
|
|
if (folderRelPath.Length > 0)
|
|
{
|
|
foreach (var segment in folderRelPath.Split('/'))
|
|
if (string.IsNullOrEmpty(segment) || segment == "." || segment == "..")
|
|
return false;
|
|
}
|
|
relPath = string.IsNullOrEmpty(folderRelPath) ? childName! : folderRelPath + "/" + childName!;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A queued folder awaiting per-folder processing: the local source entry to
|
|
/// enumerate and the relative path on the remote destination that it maps to.
|
|
/// </summary>
|
|
private sealed record FolderWorkItem(ISourceProviderEntry Entry, string RelativePath);
|
|
|
|
/// <summary>
|
|
/// A remote child entry (file) observed in a folder, as returned by a listing or
|
|
/// read from the inventory cache. Keyed by name within the folder.
|
|
/// </summary>
|
|
private sealed record RemoteChild(string Name, long Size, DateTime LastModified, string? ContentHash);
|
|
|
|
/// <summary>
|
|
/// Running counters for the plan summary log, bumped per operation as it is
|
|
/// planned/performed so the final log line reflects the whole run. The same
|
|
/// counters are flushed into <see cref="SyncResults"/> at the end of the run.
|
|
/// </summary>
|
|
private sealed class PlanSummary
|
|
{
|
|
public long Upload;
|
|
public long Update;
|
|
public long Delete;
|
|
public long FoldersCreated;
|
|
public long UnchangedFiles;
|
|
public long SourceFiles;
|
|
public long SizeOfSourceFiles;
|
|
public long SizeOfUploadedFiles;
|
|
public long SizeOfDeletedFiles;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Intermediate record holding a manager and the associated database
|
|
/// </summary>
|
|
/// <param name="BackendManager">The backend manager</param>
|
|
/// <param name="Database">The database for the manager</param>
|
|
private sealed record ManagerInstance(
|
|
IBackendManager BackendManager,
|
|
LocalSyncDatabase Database
|
|
);
|
|
|
|
/// <summary>
|
|
/// Per-backend run state for the shared single-enumeration sync: the backend
|
|
/// manager + database pair, the resolved <see cref="SyncRemoteState"/> for this
|
|
/// run (after the UseLocalState-fallback is applied), and whether the inventory
|
|
/// cache is maintained for this backend. Computed once per backend before the
|
|
/// shared folder loop so the per-folder fan-out applies the same observed local
|
|
/// state to every backend without re-resolving (or re-enumerating) per backend.
|
|
/// </summary>
|
|
private sealed record BackendRunState(
|
|
IBackendManager BackendManager,
|
|
LocalSyncDatabase Database,
|
|
SyncRemoteState StateMode,
|
|
bool MaintainInventory
|
|
);
|
|
} |