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