This PR adds a flag to the test method so we can choose to test for read-only access (needed for restore) or read-write (needed for backups). Additionally, this PR fixes a logic bug in that the BackendSourceProvider would not actually test the connection, but just check the placeholder element returned (which is always returned). With this PR the backend's ListAsync command is tested fully. Also renamed the methods on ISourceProvider to have the `...Async` prefix. This replaces #6939
548 lines
19 KiB
C#
548 lines
19 KiB
C#
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
|
|
|
using System.Collections.Concurrent;
|
|
using Duplicati.Library.Common.IO;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Logging;
|
|
using Duplicati.Library.Utility;
|
|
using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
|
|
|
|
namespace Duplicati.Proprietary.GoogleWorkspace;
|
|
|
|
public partial class RestoreProvider : IRestoreDestinationProviderModule
|
|
{
|
|
/// <summary>
|
|
/// Log tag for RestoreProvider
|
|
/// </summary>
|
|
private static readonly string LOGTAG = Log.LogTagFromType<RestoreProvider>();
|
|
|
|
/// <summary>
|
|
/// API helper for accessing Google Workspace APIs
|
|
/// </summary>
|
|
private readonly APIHelper _apiHelper;
|
|
/// <summary>
|
|
/// Path to restore to
|
|
/// </summary>
|
|
private readonly string _restorePath;
|
|
/// <summary>
|
|
/// Whether the overwrite option has been set
|
|
/// </summary>
|
|
private readonly bool _hasSetOverwriteOption;
|
|
/// <summary>
|
|
/// Source provider for accessing currently existing items on the destination
|
|
/// </summary>
|
|
internal SourceProvider SourceProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Properties related to the restore target
|
|
/// </summary>
|
|
/// <param name="Path">The path to restore to</param>
|
|
/// <param name="Entry">The source provider entry to restore to</param>
|
|
/// <param name="Metadata">The metadata associated with the restore target</param>
|
|
/// <param name="Type">The type of the restore target</param>
|
|
internal record RestoreTargetData(
|
|
string Path,
|
|
ISourceProviderEntry Entry,
|
|
Dictionary<string, string?> Metadata,
|
|
SourceItemType Type);
|
|
|
|
/// <summary>
|
|
/// The restore target data
|
|
/// </summary>
|
|
internal RestoreTargetData? RestoreTarget { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Temporary files created during the restore process
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, TempFile> _temporaryFiles = new();
|
|
/// <summary>
|
|
/// Metadata recorded during the restore process
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, Dictionary<string, string?>> _metadata = new();
|
|
/// <summary>
|
|
/// Map of restored Gmail labels to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredLabelMap = new();
|
|
|
|
/// <summary>
|
|
/// Map of restored Drive folders to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredDriveFolderMap = new();
|
|
|
|
/// <summary>
|
|
/// Map of restored task lists to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredTaskListMap = new();
|
|
|
|
/// <summary>
|
|
/// Map of restored Drive files to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredDriveFileMap = new();
|
|
|
|
/// <summary>
|
|
/// Whether to overwrite existing items
|
|
/// </summary>
|
|
internal readonly bool _ignoreExisting;
|
|
|
|
/// <summary>
|
|
/// Default constructor for the restore provider.
|
|
/// Only used for loading metadata about the provider.
|
|
/// </summary>
|
|
public RestoreProvider()
|
|
{
|
|
_apiHelper = null!;
|
|
_restorePath = null!;
|
|
SourceProvider = null!;
|
|
_hasSetOverwriteOption = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constructs the RestoreProvider with the given URL and options.
|
|
/// </summary>
|
|
/// <param name="url">The destination URL for the restore operation</param>
|
|
/// <param name="options">The options for the restore operation</param>
|
|
public RestoreProvider(string url, Dictionary<string, string?> options)
|
|
{
|
|
var uri = new Library.Utility.Uri(url);
|
|
_restorePath = uri.HostAndPath;
|
|
|
|
var parsedOptions = OptionsHelper.ParseOptions(options);
|
|
_apiHelper = new APIHelper(parsedOptions, isRestoreOperation: true);
|
|
|
|
_ignoreExisting = Utility.ParseBoolOption(options, OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION);
|
|
_hasSetOverwriteOption = Utility.ParseBoolOption(options, "overwrite");
|
|
|
|
var sourceOpts = new Dictionary<string, string?>(options);
|
|
sourceOpts["store-metadata-content-in-database"] = "true";
|
|
|
|
SourceProvider = new SourceProvider("googleworkspace://", "", sourceOpts, true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string Key => OptionsHelper.ModuleKey;
|
|
|
|
/// <inheritdoc />
|
|
public string DisplayName => Strings.Common.DisplayName;
|
|
|
|
/// <inheritdoc />
|
|
public string Description => Strings.Common.Description;
|
|
|
|
/// <inheritdoc />
|
|
public IList<ICommandLineArgument> SupportedCommands => OptionsHelper.RestoreSupportedCommands.ToList();
|
|
|
|
/// <inheritdoc />
|
|
public string TargetDestination => _restorePath;
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> CreateFolderIfNotExists(string path, CancellationToken cancel)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task Initialize(CancellationToken cancel)
|
|
{
|
|
if (!_hasSetOverwriteOption)
|
|
throw new UserInformationException(Strings.RestoreTargetMissingOverwriteOption("overwrite", OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION), "OverwriteOptionNotSet");
|
|
|
|
await SourceProvider.InitializeAsync(cancel);
|
|
var entry = await SourceProvider.GetEntryAsync(_restorePath, isFolder: true, cancel);
|
|
if (entry == null)
|
|
throw new UserInformationException(Strings.RestoreTargetNotFound(_restorePath), "RestoreTargetNotFound");
|
|
var metadata = await entry.GetMinorMetadata(cancel);
|
|
var type = metadata.TryGetValue("gsuite:Type", out var typeStr)
|
|
&& Enum.TryParse<SourceItemType>(typeStr, out var sourceItemType)
|
|
? sourceItemType
|
|
: throw new UserInformationException(Strings.InvalidRestoreTargetType(typeStr), "InvalidRestoreTargetType");
|
|
|
|
RestoreTarget = new RestoreTargetData(_restorePath, entry, metadata, type);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task Test(CancellationToken cancellationToken)
|
|
{
|
|
_apiHelper.TestConnection();
|
|
|
|
// If we have no path, just check that we can connect
|
|
if (string.IsNullOrWhiteSpace(_restorePath))
|
|
return;
|
|
|
|
// Otherwise, check that the path exists and is a valid target
|
|
await Initialize(cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes the given path by applying the RemovedRestorePrefix if it has been removed
|
|
/// </summary>
|
|
/// <param name="path">The path to normalize</param>
|
|
/// <returns>The normalized path</returns>
|
|
private string NormalizePath(string path)
|
|
=> path;
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
SourceProvider?.Dispose();
|
|
|
|
foreach (var file in _temporaryFiles.Values)
|
|
file.Dispose();
|
|
_temporaryFiles.Clear();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> FileExists(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return true;
|
|
|
|
var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
return entry != null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenWrite(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
var file = _temporaryFiles.GetOrAdd(path, _ => new TempFile());
|
|
return Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenWrite(file));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Stream> OpenRead(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return SystemIO.IO_OS.FileOpenRead(_temporaryFiles[path]);
|
|
|
|
var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
if (entry != null)
|
|
return await entry.OpenRead(cancel).ConfigureAwait(false);
|
|
|
|
throw new FileNotFoundException($"File not found: {path}");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Stream> OpenReadWrite(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]);
|
|
|
|
var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
if (entry == null)
|
|
{
|
|
_temporaryFiles.GetOrAdd(path, _ => new TempFile());
|
|
return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]);
|
|
}
|
|
|
|
if (entry.IsFolder)
|
|
throw new FileNotFoundException($"Path is a folder: {path}");
|
|
|
|
var file = new TempFile();
|
|
using (var fs = SystemIO.IO_OS.FileOpenWrite(file))
|
|
using (var stream = await entry.OpenRead(cancel).ConfigureAwait(false))
|
|
await stream.CopyToAsync(fs).ConfigureAwait(false);
|
|
|
|
_temporaryFiles[path] = file;
|
|
return SystemIO.IO_OS.FileOpenReadWrite(file);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<long> GetFileLength(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return SystemIO.IO_OS.FileLength(_temporaryFiles[path]);
|
|
|
|
var entry = await SourceProvider.GetEntryAsync(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
if (entry == null)
|
|
throw new FileNotFoundException($"File not found: {path}");
|
|
|
|
return entry.Size;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> HasReadOnlyAttribute(string path, CancellationToken cancel)
|
|
=> Task.FromResult(false);
|
|
|
|
/// <inheritdoc />
|
|
public Task ClearReadOnlyAttribute(string path, CancellationToken cancel)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> WriteMetadata(string path, Dictionary<string, string?> metadata, bool restoreSymlinkMetadata, bool restorePermissions, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
_metadata.AddOrUpdate(path, metadata, (_, _) => metadata);
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteFolder(string path, CancellationToken cancel)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteFile(string path, CancellationToken cancel)
|
|
=> throw new NotImplementedException("File deletion is not supported in Google Workspace RestoreProvider");
|
|
|
|
/// <inheritdoc />
|
|
public async Task Finalize(Action<double>? progressCallback, CancellationToken cancel)
|
|
{
|
|
var totalFiles = _metadata.Count;
|
|
var completedPhases = 0;
|
|
const int totalPhases = 13;
|
|
|
|
// Phase 1: Restore Gmail Labels
|
|
try
|
|
{
|
|
await RestoreGmailLabels(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailLabelsFailed", ex, $"Failed to restore Gmail labels");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 2: Restore Gmail Messages
|
|
try
|
|
{
|
|
await RestoreGmailMessages(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailMessagesFailed", ex, $"Failed to restore Gmail messages");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 3: Restore Gmail Settings
|
|
try
|
|
{
|
|
await RestoreGmailSettings(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailSettingsFailed", ex, $"Failed to restore Gmail settings");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 4: Restore Drive Folders
|
|
try
|
|
{
|
|
await RestoreDriveFolders(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFoldersFailed", ex, $"Failed to restore Drive folders");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 5: Restore Drive Files
|
|
try
|
|
{
|
|
await RestoreDriveFiles(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFilesFailed", ex, $"Failed to restore Drive files");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 6: Restore Drive Permissions
|
|
try
|
|
{
|
|
await RestoreDrivePermissions(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDrivePermissionsFailed", ex, $"Failed to restore Drive permissions");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 7: Restore Calendar Events
|
|
try
|
|
{
|
|
await RestoreCalendarEvents(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 8: Restore Calendar ACLs
|
|
try
|
|
{
|
|
await RestoreCalendarAcls(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 9: Restore Contacts
|
|
try
|
|
{
|
|
await RestoreContacts(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactsFailed", ex, $"Failed to restore contacts");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 10: Restore Contact Groups
|
|
try
|
|
{
|
|
await RestoreContactGroups(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactGroupsFailed", ex, $"Failed to restore contact groups");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 11: Restore Task Lists and Tasks
|
|
try
|
|
{
|
|
await RestoreTaskLists(cancel).ConfigureAwait(false);
|
|
await RestoreTasks(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTasksFailed", ex, $"Failed to restore tasks");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 12: Restore Keep Notes (if target supports it)
|
|
try
|
|
{
|
|
await RestoreKeepNotes(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreKeepNotesFailed", ex, $"Failed to restore Keep notes");
|
|
}
|
|
|
|
// Phase 13: Restore Chat Messages (if target supports it)
|
|
try
|
|
{
|
|
await RestoreChatMessages(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreChatMessagesFailed", ex, $"Failed to restore chat messages");
|
|
}
|
|
|
|
RemoveMetaEntriesForNonRestoredItems();
|
|
|
|
// We should have restored all items by now
|
|
if (_metadata.Count > 0 || _temporaryFiles.Count > 0)
|
|
{
|
|
var displayItems = _metadata.Values
|
|
.Select(x => (Type: x.GetValueOrDefault("gsuite:Type"), Name: x.GetValueOrDefault("gsuite:Name") ?? x.GetValueOrDefault("gsuite:DisplayName")))
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Type) && !string.IsNullOrWhiteSpace(x.Name))
|
|
.Take(10);
|
|
|
|
foreach (var item in displayItems)
|
|
Log.WriteWarningMessage(LOGTAG, "FinalizeIncompleteItem", null, $"Unrestored item - Type: {item.Type}, Name: {item.Name}");
|
|
|
|
if (_metadata.Count > displayItems.Count())
|
|
Log.WriteWarningMessage(LOGTAG, "FinalizeIncomplete", null, $"... and {_metadata.Count - displayItems.Count()} more unrestored items.");
|
|
}
|
|
|
|
var tempFiles = _temporaryFiles.Values.ToList();
|
|
_temporaryFiles.Clear();
|
|
foreach (var file in tempFiles)
|
|
file.Dispose();
|
|
_metadata.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets metadata entries by their source item type
|
|
/// </summary>
|
|
/// <param name="type">The source item type to filter by</param>
|
|
/// <returns>The list of metadata entries matching the given type</returns>
|
|
private List<KeyValuePair<string, Dictionary<string, string?>>> GetMetadataByType(SourceItemType type)
|
|
=> _metadata
|
|
.Where(kv => kv.Value.TryGetValue("gsuite:Type", out var typeStr)
|
|
&& typeStr == type.ToString())
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Removes metadata entries for items that were not restored and are not required to be processed
|
|
/// </summary>
|
|
private void RemoveMetaEntriesForNonRestoredItems()
|
|
{
|
|
var toRemove = new HashSet<string>();
|
|
foreach (var kv in _metadata)
|
|
{
|
|
// Some intermediate directories may not have a type
|
|
if (!kv.Value.ContainsKey("gsuite:Type"))
|
|
{
|
|
toRemove.Add(kv.Key);
|
|
continue;
|
|
}
|
|
|
|
var key = kv.Value.GetValueOrDefault("gsuite:Type");
|
|
if (!Enum.TryParse<SourceItemType>(key, out var type))
|
|
continue;
|
|
|
|
switch (type)
|
|
{
|
|
// Various meta-types that are not restored directly
|
|
case SourceItemType.User:
|
|
case SourceItemType.UserGmail:
|
|
case SourceItemType.UserDrive:
|
|
case SourceItemType.UserCalendar:
|
|
case SourceItemType.UserContacts:
|
|
case SourceItemType.UserTasks:
|
|
case SourceItemType.UserKeep:
|
|
case SourceItemType.UserChat:
|
|
case SourceItemType.Calendar:
|
|
toRemove.Add(kv.Key);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// If we are restoring TO a specific label, we don't need to restore the label itself
|
|
if (type == SourceItemType.GmailLabel && RestoreTarget != null && RestoreTarget.Type == SourceItemType.GmailLabel)
|
|
toRemove.Add(kv.Key);
|
|
|
|
// If we are restoring TO a specific folder, we don't need to restore the folder itself
|
|
if (type == SourceItemType.DriveFolder && RestoreTarget != null && RestoreTarget.Type == SourceItemType.DriveFolder)
|
|
toRemove.Add(kv.Key);
|
|
}
|
|
|
|
foreach (var key in toRemove)
|
|
{
|
|
_metadata.TryRemove(key, out _);
|
|
_temporaryFiles.TryRemove(key, out var f);
|
|
f?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IList<string> GetPriorityFiles() => new List<string>();
|
|
}
|