diff --git a/Duplicati/WebserverCore/Dto/V2/DestinationListRequestDto.cs b/Duplicati/WebserverCore/Dto/V2/DestinationListRequestDto.cs
new file mode 100644
index 000000000..010b0c026
--- /dev/null
+++ b/Duplicati/WebserverCore/Dto/V2/DestinationListRequestDto.cs
@@ -0,0 +1,47 @@
+namespace Duplicati.WebserverCore.Dto.V2;
+
+///
+/// The request object for the destination list endpoint.
+///
+public class DestinationListRequestDto
+{
+ ///
+ /// The backup ID to list destination content for.
+ ///
+ public required string? BackupId { get; set; }
+
+ ///
+ /// The connection string ID, if known
+ ///
+ public long? ConnectionStringId { get; init; }
+
+ ///
+ /// The destination URL to list destination content for.
+ ///
+ public required string DestinationUrl { get; set; }
+
+ ///
+ /// The type of remote destination
+ ///
+ public RemoteDestinationType? DestinationType { get; init; }
+
+ ///
+ /// The source prefix to identify the source provider connection string
+ ///
+ public string? SourcePrefix { get; init; }
+
+ ///
+ /// The path to list destination content for.
+ ///
+ public required string Path { get; set; }
+
+ ///
+ /// The offset to start listing from.
+ ///
+ public required int? Offset { get; set; }
+
+ ///
+ /// The maximum number of items to return.
+ ///
+ public required int? Limit { get; set; }
+}
diff --git a/Duplicati/WebserverCore/Dto/V2/DestinationListResponseDto.cs b/Duplicati/WebserverCore/Dto/V2/DestinationListResponseDto.cs
new file mode 100644
index 000000000..1df5f088f
--- /dev/null
+++ b/Duplicati/WebserverCore/Dto/V2/DestinationListResponseDto.cs
@@ -0,0 +1,83 @@
+namespace Duplicati.WebserverCore.Dto.V2;
+
+///
+/// A single item in the destination list response
+///
+public sealed record DestinationListResponseItem
+{
+ ///
+ /// The item path
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// The item size
+ ///
+ public required long Size { get; init; }
+
+ ///
+ /// Any metadata associated with the item
+ ///
+ public required IDictionary? Metadata { get; init; }
+}
+
+///
+/// The content of the destination list response
+///
+public sealed record DestinationListResponesContent
+{
+ ///
+ /// The items in the response
+ ///
+ public required IEnumerable Items { get; init; }
+ ///
+ /// The offset of the first item in the response
+ ///
+ public required int Offset { get; init; }
+ ///
+ /// Whether there are more items to return
+ ///
+ public required bool HasMore { get; init; }
+}
+
+///
+/// The response from the destination list endpoint
+///
+public sealed record DestinationListResponseDto : ResponseEnvelope
+{
+ ///
+ /// Creates a failure response
+ ///
+ /// The error message
+ /// The response
+ public static DestinationListResponseDto Failure(string error)
+ => new DestinationListResponseDto()
+ {
+ Error = error,
+ StatusCode = "Failed",
+ Success = false,
+ Data = null
+ };
+
+ ///
+ /// Creates a success response
+ ///
+ /// The items to return
+ /// The offset of the first item
+ /// Whether there are more items to return
+ /// The response
+ public static DestinationListResponseDto Create(IEnumerable items, int offset, bool hasMore)
+ => new DestinationListResponseDto()
+ {
+ Error = null,
+ StatusCode = "Success",
+ Success = true,
+ Data = new DestinationListResponesContent()
+ {
+ Items = items,
+ Offset = offset,
+ HasMore = hasMore
+ }
+ };
+
+}
diff --git a/Duplicati/WebserverCore/Endpoints/Shared/SharedRemoteOperation.cs b/Duplicati/WebserverCore/Endpoints/Shared/SharedRemoteOperation.cs
index 6091b126b..0fea5b909 100644
--- a/Duplicati/WebserverCore/Endpoints/Shared/SharedRemoteOperation.cs
+++ b/Duplicati/WebserverCore/Endpoints/Shared/SharedRemoteOperation.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using Duplicati.Library.Utility;
@@ -98,28 +99,45 @@ public class SharedRemoteOperation
return (url, opts);
}
- public static async Task GetBackendAsync(Connection connection, IApplicationSettings applicationSettings, string url, string? backupId, long connectionStringId, CancellationToken cancelToken)
+ [return: NotNullIfNotNull(nameof(url))]
+ private static string? AppendAdditionalPath(string? url, string? additionalPath)
+ {
+ if (string.IsNullOrEmpty(additionalPath))
+ return url;
+
+ if (additionalPath.Contains('?') || additionalPath.Contains('#') || additionalPath.Contains('*') || additionalPath.Contains(".."))
+ throw new UserInformationException("The additional path contains invalid characters", "InvalidPath");
+
+ if (string.IsNullOrEmpty(url))
+ return url;
+
+ var source = new Library.Utility.Uri(url);
+ var path = (source.Path ?? "").TrimEnd('/') + "/" + additionalPath.TrimStart('/');
+ return new UriBuilder(url) { Path = path }.Uri.AbsoluteUri;
+ }
+
+ public static async Task GetBackendAsync(Connection connection, IApplicationSettings applicationSettings, string url, string? additionalPath, string? backupId, long connectionStringId, CancellationToken cancelToken)
{
(url, var opts) = await ExpandUrlAsync(connection, applicationSettings, url, backupId, connectionStringId, null, cancelToken);
var modules = ConfigureModules(opts);
- var backend = Library.DynamicLoader.BackendLoader.GetBackend(url, opts);
+ var backend = Library.DynamicLoader.BackendLoader.GetBackend(AppendAdditionalPath(url, additionalPath), opts);
return new BackendTupleDisposeWrapper(backend, modules);
}
- public static async Task GetSourceProviderForTestingAsync(Connection connection, IApplicationSettings applicationSettings, string url, string? backupId, long connectionStringId, string? sourcePrefix, CancellationToken cancelToken)
+ public static async Task GetSourceProviderForTestingAsync(Connection connection, IApplicationSettings applicationSettings, string url, string? additionalPath, string? backupId, long connectionStringId, string? sourcePrefix, CancellationToken cancelToken)
{
(url, var opts) = await ExpandUrlAsync(connection, applicationSettings, url, backupId, connectionStringId, sourcePrefix, cancelToken);
var modules = ConfigureModules(opts);
- var sourceProvider = await Library.DynamicLoader.SourceProviderLoader.GetSourceProviderForTesting(url, "", opts, cancelToken);
+ var sourceProvider = await Library.DynamicLoader.SourceProviderLoader.GetSourceProviderForTesting(AppendAdditionalPath(url, additionalPath), "", opts, cancelToken);
return new SourceProviderTupleDisposeWrapper(sourceProvider, modules);
}
- public static async Task GetRestoreDestinationProviderForTestingAsync(Connection connection, IApplicationSettings applicationSettings, string url, string? backupId, long connectionStringId, string? sourcePrefix, CancellationToken cancelToken)
+ public static async Task GetRestoreDestinationProviderForTestingAsync(Connection connection, IApplicationSettings applicationSettings, string url, string? additionalPath, string? backupId, long connectionStringId, string? sourcePrefix, CancellationToken cancelToken)
{
(url, var opts) = await ExpandUrlAsync(connection, applicationSettings, url, backupId, connectionStringId, sourcePrefix, cancelToken);
var modules = ConfigureModules(opts);
- var restoreDestinationProvider = await Library.DynamicLoader.RestoreDestinationProviderLoader.GetRestoreDestinationProviderForTesting(url, opts, cancelToken);
+ var restoreDestinationProvider = await Library.DynamicLoader.RestoreDestinationProviderLoader.GetRestoreDestinationProviderForTesting(AppendAdditionalPath(url, additionalPath), opts, cancelToken);
return new RestoreDestinationProviderTupleDisposeWrapper(restoreDestinationProvider, modules);
}
diff --git a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs
index 29faa99d4..2d0e2f44d 100644
--- a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs
+++ b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs
@@ -60,19 +60,19 @@ namespace Duplicati.WebserverCore.Endpoints.V1
{
if (type == Dto.V2.RemoteDestinationType.SourceProvider)
{
- using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, maskedurl, backupId, connectionStringId ?? -1, sourcePrefix, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, maskedurl, null, backupId, connectionStringId ?? -1, sourcePrefix, cancelToken);
using (var s = wrapper.SourceProvider)
await s.TestAsync(cancelToken).ConfigureAwait(false);
}
else if (type == Dto.V2.RemoteDestinationType.RestoreDestinationProvider)
{
- using var wrapper = await SharedRemoteOperation.GetRestoreDestinationProviderForTestingAsync(connection, applicationSettings, maskedurl, backupId, connectionStringId ?? -1, sourcePrefix, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetRestoreDestinationProviderForTestingAsync(connection, applicationSettings, maskedurl, null, backupId, connectionStringId ?? -1, sourcePrefix, cancelToken);
using (var r = wrapper.RestoreDestinationProvider)
await r.Test(cancelToken).ConfigureAwait(false);
}
else
{
- using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, maskedurl, backupId, connectionStringId ?? -1, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, maskedurl, null, backupId, connectionStringId ?? -1, cancelToken);
using (var b = wrapper.Backend)
{
try { await b.TestAsync(!readOnlyTest, cancelToken).ConfigureAwait(false); }
@@ -124,7 +124,7 @@ namespace Duplicati.WebserverCore.Endpoints.V1
{
try
{
- using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, maskedurl, backupId, connectionStringId ?? -1, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, maskedurl, null, backupId, connectionStringId ?? -1, cancelToken);
using (var b = wrapper.Backend)
await b.CreateFolderAsync(cancelToken).ConfigureAwait(false);
}
diff --git a/Duplicati/WebserverCore/Endpoints/V2/DestinationList.cs b/Duplicati/WebserverCore/Endpoints/V2/DestinationList.cs
new file mode 100644
index 000000000..9a041a5df
--- /dev/null
+++ b/Duplicati/WebserverCore/Endpoints/V2/DestinationList.cs
@@ -0,0 +1,137 @@
+// Copyright (C) 2026, The Duplicati Team
+// https://duplicati.com, hello@duplicati.com
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the "Software"),
+// to deal in the Software without restriction, including without limitation
+// the rights to use, copy, modify, merge, publish, distribute, sublicense,
+// and/or sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Server.Database;
+using Duplicati.WebserverCore.Abstractions;
+using Duplicati.WebserverCore.Dto.V2;
+using Duplicati.WebserverCore.Endpoints.Shared;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Duplicati.WebserverCore.Endpoints.V2;
+
+public class DestinationList : IEndpointV2
+{
+ private const int MAX_LIMIT = 200;
+
+ public static void Map(RouteGroupBuilder group)
+ {
+ group.MapPost("/destination/list", ([FromServices] Connection connection, [FromServices] IApplicationSettings applicationSettings, [FromBody] DestinationListRequestDto input, CancellationToken cancelToken)
+ => ExecuteTestAsync(connection, applicationSettings, input, cancelToken))
+ .RequireAuthorization();
+ }
+
+ private static async Task ExecuteTestAsync(Connection connection, IApplicationSettings applicationSettings, DestinationListRequestDto input, CancellationToken cancelToken)
+ {
+ var destinationType = input.DestinationType ?? RemoteDestinationType.Backend;
+
+ var offset = input.Offset ?? 0;
+ var limit = input.Limit ?? MAX_LIMIT;
+ if (offset < 0 || offset > int.MaxValue - MAX_LIMIT)
+ return DestinationListResponseDto.Failure($"Offset must be greater than or equal to 0 and less than or equal to {int.MaxValue - MAX_LIMIT}");
+ if (limit < 0 || limit > MAX_LIMIT)
+ return DestinationListResponseDto.Failure($"Limit must be greater than or equal to 0 and less than or equal to {MAX_LIMIT}");
+
+
+ try
+ {
+ if (destinationType == RemoteDestinationType.SourceProvider)
+ {
+ using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, null, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken);
+
+ // Initialize the source provider, not done with "getForTesting"
+ await wrapper.SourceProvider.InitializeAsync(cancelToken);
+
+ // Obtain the list items
+ var entry = await wrapper.SourceProvider.GetEntryAsync(input.Path, true, cancelToken);
+ if (entry == null || !entry.IsFolder)
+ return DestinationListResponseDto.Failure("Folder does not exist");
+
+ var entries = await entry.Enumerate(cancelToken)
+ .Skip(offset)
+ .Take(limit)
+ .ToListAsync(cancelToken);
+
+ var items = new List(entries.Count);
+ foreach (var e in entries)
+ items.Add(new DestinationListResponseItem
+ {
+ Path = e.Path,
+ Metadata = await e.GetMinorMetadata(cancelToken),
+ Size = e.Size
+ });
+
+ return DestinationListResponseDto.Create(
+ items, offset, hasMore: items.Count() == limit
+ );
+ }
+ else
+ {
+ string pathCombine(string additionalPath, IFileEntry entry)
+ {
+ var name = $"{additionalPath?.TrimEnd('/')}/{entry.Name?.TrimStart('/')}";
+ if (entry.IsFolder)
+ Util.AppendDirSeparator(name, "/");
+ return name;
+ }
+
+ using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, input.DestinationUrl, input.Path, input.BackupId, input.ConnectionStringId ?? -1, cancelToken);
+
+ using (var b = wrapper.Backend)
+ {
+ var items = await b.ListAsync(cancelToken)
+ .Skip(offset)
+ .Take(limit)
+ .Select(x => new DestinationListResponseItem
+ {
+ Path = pathCombine(input.Path, x),
+ Metadata = null,
+ Size = x.Size
+ })
+ .ToListAsync(cancelToken);
+
+ return DestinationListResponseDto.Create(
+ items, offset, hasMore: items.Count() == limit
+ );
+ }
+ }
+ }
+ catch (Exception ex) when (SharedRemoteOperation.GetInnerException(ex) is FolderMissingException)
+ {
+ return DestinationListResponseDto.Failure(
+ "Folder does not exist"
+ );
+ }
+ catch (UserInformationException uex)
+ {
+ return DestinationListResponseDto.Failure(
+ uex.Message
+ );
+ }
+ catch (Exception ex)
+ {
+ return DestinationListResponseDto.Failure(
+ ex.Message
+ );
+ }
+ }
+}
diff --git a/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs b/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs
index 70f1fd4dd..a37fda594 100644
--- a/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs
+++ b/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs
@@ -47,7 +47,7 @@ public class DestinationVerify : IEndpointV2
{
if (destinationType == RemoteDestinationType.SourceProvider)
{
- using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetSourceProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, null, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken);
// Call the specific Test method on the ISourceProvider (read-only test)
await wrapper.SourceProvider.TestAsync(cancelToken);
@@ -60,7 +60,7 @@ public class DestinationVerify : IEndpointV2
}
else if (destinationType == RemoteDestinationType.RestoreDestinationProvider)
{
- using var wrapper = await SharedRemoteOperation.GetRestoreDestinationProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetRestoreDestinationProviderForTestingAsync(connection, applicationSettings, input.DestinationUrl, null, input.BackupId, input.ConnectionStringId ?? -1, input.SourcePrefix, cancelToken);
// Call the specific Test method on the IRestoreDestinationProvider (should be a write test)
await wrapper.RestoreDestinationProvider.Test(cancelToken);
@@ -73,7 +73,7 @@ public class DestinationVerify : IEndpointV2
}
else
{
- using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, input.DestinationUrl, input.BackupId, input.ConnectionStringId ?? -1, cancelToken);
+ using var wrapper = await SharedRemoteOperation.GetBackendAsync(connection, applicationSettings, input.DestinationUrl, null, input.BackupId, input.ConnectionStringId ?? -1, cancelToken);
using (var b = wrapper.Backend)
{
diff --git a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs
index d98e99d09..0a763f97e 100644
--- a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs
+++ b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs
@@ -59,7 +59,8 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec
"v2:system:temp-disk-space",
"v1:subscribe:remotecontrol",
"v1:ipc:controller",
- "v2:filesystem:test-filter"
+ "v2:filesystem:test-filter",
+ "v2:destination:list",
// "v1:subscribe:scheduler",
];
diff --git a/proprietary/DiskImage/SourceItems/MachineRootSourceEntry.cs b/proprietary/DiskImage/SourceItems/MachineRootSourceEntry.cs
new file mode 100644
index 000000000..ebbbfccc5
--- /dev/null
+++ b/proprietary/DiskImage/SourceItems/MachineRootSourceEntry.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.DiskImage.SourceItems;
+
+public class MachineRootSourceEntry() : ISourceProviderEntry
+{
+ public bool IsFolder => true;
+
+ public bool IsMetaEntry => true;
+
+ public bool IsRootEntry => true;
+
+ public DateTime CreatedUtc => new DateTime(0);
+
+ public DateTime LastModificationUtc => new DateTime(0);
+
+ public string Path => "/";
+
+ public long Size => -1;
+
+ public bool IsSymlink => false;
+
+ public string? SymlinkTarget => null;
+
+ public FileAttributes Attributes => FileAttributes.None;
+
+ public bool IsBlockDevice => true;
+
+ public bool IsCharacterDevice => false;
+
+ public bool IsAlternateStream => false;
+
+ public string? HardlinkTargetId => null;
+
+ public IAsyncEnumerable Enumerate(CancellationToken cancellationToken)
+ => SourceProvider.ListPhysicalDrives(cancellationToken);
+
+ public Task FileExists(string filename, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "diskimage:Type", "root" },
+ });
+
+ public Task OpenRead(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/proprietary/DiskImage/SourceItems/PhysicalDriveSourceEntry.cs b/proprietary/DiskImage/SourceItems/PhysicalDriveSourceEntry.cs
new file mode 100644
index 000000000..a0567fcdb
--- /dev/null
+++ b/proprietary/DiskImage/SourceItems/PhysicalDriveSourceEntry.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Duplicati.Proprietary.DiskImage.General;
+
+namespace Duplicati.Proprietary.DiskImage.SourceItems;
+
+public class PhysicalDriveSourceEntry(PhysicalDriveInfo driveInfo) : ISourceProviderEntry
+{
+ public bool IsFolder => true;
+
+ public bool IsMetaEntry => true;
+
+ public bool IsRootEntry => false;
+
+ public DateTime CreatedUtc => new DateTime(0);
+
+ public DateTime LastModificationUtc => new DateTime(0);
+
+ public string Path => Util.AppendDirSeparator(driveInfo.Path);
+
+ public long Size => (long)driveInfo.Size;
+
+ public bool IsSymlink => false;
+
+ public string? SymlinkTarget => null;
+
+ public FileAttributes Attributes => FileAttributes.None;
+
+ public bool IsBlockDevice => true;
+
+ public bool IsCharacterDevice => false;
+
+ public bool IsAlternateStream => false;
+
+ public string? HardlinkTargetId => null;
+
+ public IAsyncEnumerable Enumerate(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task FileExists(string filename, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary
+ {
+ { "diskimage:Type", "physicaldrive" },
+ { "diskimage:Number", driveInfo.Number },
+ { "diskimage:FriendlyName", driveInfo.DisplayName },
+ { "diskimage:Size", driveInfo.Size.ToString() },
+ { "diskimage:DevicePath", driveInfo.Path },
+ { "diskimage:Name", $"{driveInfo.DisplayName} ({Library.Utility.Utility.FormatSizeString(driveInfo.Size)})" },
+ });
+
+ public Task OpenRead(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/proprietary/DiskImage/SourceProvider.cs b/proprietary/DiskImage/SourceProvider.cs
index 1904bdd85..46bc02e4a 100644
--- a/proprietary/DiskImage/SourceProvider.cs
+++ b/proprietary/DiskImage/SourceProvider.cs
@@ -4,6 +4,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
@@ -104,8 +105,10 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
///
public async Task InitializeAsync(CancellationToken cancellationToken)
{
+ // TODO: Should we redesign this?
+ // To support enumerating physical drives, we accept init with no disk
if (string.IsNullOrEmpty(_devicePath))
- throw new UserInformationException("Disk device path is not specified.", "DiskDeviceNotSpecified");
+ return;
if (OperatingSystem.IsWindows())
{
@@ -152,6 +155,9 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
///
public async Task GetEntryAsync(string path, bool isFolder, CancellationToken cancellationToken)
{
+ if (string.IsNullOrWhiteSpace(path) || path == "/")
+ return new MachineRootSourceEntry();
+
if (_disk == null)
throw new InvalidOperationException("Provider not initialized.");
@@ -195,14 +201,17 @@ public sealed class SourceProvider : ISourceProviderModule, IDisposable
/// Cancellation token.
/// A list of physical drive information.
/// Thrown when the platform is not supported.
- public static IAsyncEnumerable ListPhysicalDrives(CancellationToken cancellationToken)
+ public static IAsyncEnumerable ListPhysicalDrives(CancellationToken cancellationToken)
{
if (OperatingSystem.IsWindows())
- return Windows.ListPhysicalDrivesAsync(cancellationToken);
+ return Windows.ListPhysicalDrivesAsync(cancellationToken)
+ .Select(x => new PhysicalDriveSourceEntry(x));
else if (OperatingSystem.IsMacOS())
- return Mac.ListPhysicalDrivesAsync(cancellationToken);
+ return Mac.ListPhysicalDrivesAsync(cancellationToken)
+ .Select(x => new PhysicalDriveSourceEntry(x));
else if (OperatingSystem.IsLinux())
- return Linux.ListPhysicalDrivesAsync(cancellationToken);
+ return Linux.ListPhysicalDrivesAsync(cancellationToken)
+ .Select(x => new PhysicalDriveSourceEntry(x));
else
throw new PlatformNotSupportedException(Strings.PlatformNotSupported);
}
diff --git a/proprietary/DiskImage/WebModule.cs b/proprietary/DiskImage/WebModule.cs
index beca17639..a074ddda8 100644
--- a/proprietary/DiskImage/WebModule.cs
+++ b/proprietary/DiskImage/WebModule.cs
@@ -1,14 +1,10 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.IO;
-using System.IO.Pipelines;
using System.Linq;
-using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
-using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using Duplicati.Library.Utility;
@@ -54,7 +50,8 @@ public class WebModule : IWebModule
{
var res = new Dictionary();
await foreach (var drive in SourceProvider.ListPhysicalDrives(cancellationToken))
- AddDiskToResult(drive, res);
+ res[drive.Path] = JsonSerializer.Serialize(await drive.GetMinorMetadata(cancellationToken));
+
return res;
}
@@ -90,23 +87,6 @@ public class WebModule : IWebModule
return result;
}
- private void AddDiskToResult(PhysicalDriveInfo drive, Dictionary result)
- {
- // For now, we only allow picking full disks, not individual partitions
- // var resultpath = Util.AppendDirSeparator(drive.Path);
- var resultpath = drive.Path.TrimEnd(Path.DirectorySeparatorChar);
- var metadata = new Dictionary
- {
- { "diskimage:Number", drive.Number },
- { "diskimage:FriendlyName", drive.DisplayName },
- { "diskimage:Size", drive.Size.ToString() },
- { "diskimage:DevicePath", drive.Path },
- { "diskimage:Name", $"{drive.DisplayName} ({Library.Utility.Utility.FormatSizeString(drive.Size)})" },
- };
-
- result[resultpath] = JsonSerializer.Serialize(metadata);
- }
-
public IDictionary> GetLookups()
=> new Dictionary>();
}
\ No newline at end of file