2026-02-06 06:05:34 +01:00
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System ;
using System.Collections.Concurrent ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using System.Threading ;
using System.Threading.Tasks ;
using Duplicati.Library.Interface ;
using Duplicati.Library.Logging ;
using Duplicati.Library.Utility ;
using Duplicati.Proprietary.DiskImage.Disk ;
2026-02-06 12:53:33 +01:00
using Duplicati.Proprietary.DiskImage.Filesystem ;
2026-03-05 15:30:36 +01:00
using Duplicati.Proprietary.DiskImage.General ;
2026-02-06 06:05:34 +01:00
using Duplicati.Proprietary.DiskImage.Partition ;
namespace Duplicati.Proprietary.DiskImage ;
/// <summary>
/// Restore provider for disk images. Allows restoring disk images back to physical disks.
/// </summary>
public sealed class RestoreProvider : IRestoreDestinationProviderModule , IDisposable
{
private static readonly string LOGTAG = Log . LogTagFromType < RestoreProvider >();
private readonly string _devicePath ;
private readonly string _restorePath ;
2026-02-16 10:03:10 +01:00
private readonly bool _autoUnmount ;
2026-02-06 06:05:34 +01:00
private readonly bool _skipPartitionTable ;
private readonly bool _validateSize ;
2026-02-06 12:53:33 +01:00
private readonly bool _hasSetOverwriteOption ;
2026-02-06 06:05:34 +01:00
private IRawDisk ? _targetDisk ;
private bool _disposed ;
/// <summary>
2026-02-06 12:53:33 +01:00
/// Tracks pending writes for items that need to be written during Finalize.
/// For partition table items, this stores the data to be written.
2026-02-06 06:05:34 +01:00
/// </summary>
2026-02-06 12:53:33 +01:00
private readonly ConcurrentDictionary < string , PendingWrite > _pendingWrites = new ();
2026-02-09 11:36:15 +01:00
/// <summary>
/// Stores geometry metadata parsed from restored geometry files.
/// Used to reconstruct disk, partition, and filesystem structures.
/// </summary>
2026-02-10 11:05:05 +01:00
private GeometryMetadata ? _geometryMetadata ;
private List < IPartition > _partitions = [];
private List < IFilesystem > _filesystems = [];
2026-02-09 11:36:15 +01:00
2026-02-06 12:53:33 +01:00
/// <summary>
/// Represents a pending write operation.
/// </summary>
private abstract class PendingWrite : IDisposable
{
public abstract void Dispose ();
}
/// <summary>
/// Pending write for disk-level data (stored in memory until Finalize).
/// </summary>
private class DiskPendingWrite : PendingWrite
{
2026-02-10 11:05:05 +01:00
// Empty class, as this is used for tracking whether we have to write
// disk-level data (e.g. partition table) during Finalize.
2026-02-06 12:53:33 +01:00
public override void Dispose ()
{
// Nothing to dispose
}
}
/// <summary>
/// Pending write for partition data (stored in memory until Finalize).
/// </summary>
2026-02-10 11:05:05 +01:00
private class PartitionPendingWrite ( IPartition Partition ) : PendingWrite
2026-02-06 12:53:33 +01:00
{
2026-02-10 11:05:05 +01:00
// Currently unused, but stored for potential future use if we need to
// track partition-level writes separately from disk-level writes.
public IPartition Partition { get ; } = Partition ;
2026-02-06 12:53:33 +01:00
2026-02-10 11:05:05 +01:00
// Empty class, as this is used for tracking whether we have to write
// partition-level data during Finalize. Although, this will probably
// be handled by the file system writes.
2026-02-06 12:53:33 +01:00
public override void Dispose ()
{
// Nothing to dispose
}
}
2026-02-06 06:05:34 +01:00
/// <summary>
/// Default constructor for the restore provider.
/// Only used for loading metadata about the provider.
/// </summary>
public RestoreProvider ()
{
_devicePath = null !;
_restorePath = null !;
_skipPartitionTable = false ;
_validateSize = true ;
2026-02-06 12:53:33 +01:00
_hasSetOverwriteOption = false ;
2026-02-06 06:05:34 +01:00
}
/// <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 ;
2026-02-06 10:53:44 +01:00
_devicePath = uri . HostAndPath ;
2026-02-06 06:05:34 +01:00
_skipPartitionTable = Utility . ParseBoolOption ( options , OptionsHelper . DISK_RESTORE_SKIP_PARTITION_TABLE_OPTION );
_validateSize = Utility . ParseBoolOption ( options , OptionsHelper . DISK_RESTORE_VALIDATE_SIZE_OPTION );
2026-02-16 10:03:10 +01:00
_autoUnmount = Utility . ParseBoolOption ( options , OptionsHelper . DISK_RESTORE_AUTO_UNMOUNT_OPTION );
2026-02-06 12:53:33 +01:00
_hasSetOverwriteOption = Utility . ParseBoolOption ( options , "overwrite" );
2026-02-06 06:05:34 +01:00
}
/// <inheritdoc />
public string Key => OptionsHelper . ModuleKey ;
/// <inheritdoc />
public string DisplayName => Strings . RestoreProviderDisplayName ;
/// <inheritdoc />
public string Description => Strings . RestoreProviderDescription ;
/// <inheritdoc />
public IList < ICommandLineArgument > SupportedCommands => OptionsHelper . SupportedCommands ;
/// <inheritdoc />
public string TargetDestination => _restorePath ;
/// <inheritdoc />
public async Task Initialize ( CancellationToken cancel )
{
2026-02-16 10:03:10 +01:00
if ( string . IsNullOrEmpty ( _devicePath ))
throw new UserInformationException ( "Disk device path is not specified." , "DiskDeviceNotSpecified" );
2026-02-06 06:05:34 +01:00
2026-02-16 10:03:10 +01:00
if ( OperatingSystem . IsWindows ())
2026-02-06 06:05:34 +01:00
_targetDisk = new Windows ( _devicePath );
2026-02-16 13:41:28 +01:00
else if ( OperatingSystem . IsMacOS ())
_targetDisk = new Mac ( _devicePath );
2026-03-01 11:14:34 +01:00
else if ( OperatingSystem . IsLinux ())
_targetDisk = new Linux ( _devicePath );
2026-02-06 06:05:34 +01:00
else
2026-02-16 13:41:28 +01:00
throw new PlatformNotSupportedException ( Strings . PlatformNotSupported );
2026-02-16 10:03:10 +01:00
if ( _autoUnmount )
if (! await _targetDisk . AutoUnmountAsync ( cancel ). ConfigureAwait ( false ))
throw new UserInformationException ( $"Failed to auto unmount target disk: {_devicePath}. Ensure the disk is not in use and you have sufficient permissions." , "DiskAutoUnmountFailed" );
if (! await _targetDisk . InitializeAsync ( enableWrite : true , cancel ))
throw new UserInformationException ( string . Format ( Strings . RestoreDeviceNotWriteable , _devicePath ), "DiskInitializeFailed" );
2026-02-06 06:05:34 +01:00
// Validate target size if requested
if ( _validateSize )
{
// Size validation will be done during Finalize when we have source metadata
Log . WriteInformationMessage ( LOGTAG , "RestoreSizeValidationEnabled" , "Target size validation is enabled." );
}
}
/// <inheritdoc />
public async Task Test ( CancellationToken cancellationToken )
{
if ( _targetDisk == null )
throw new InvalidOperationException ( "Provider not initialized." );
2026-02-16 09:10:10 +01:00
if (! _targetDisk . IsWriteable )
throw new InvalidOperationException ( "Target disk is not writeable." );
// Check if we have permission to write to the target device by reading
// a sector and then writing it back.
try
{
using var sectorStream = await _targetDisk . ReadSectorsAsync ( 0 , 1 , cancellationToken ). ConfigureAwait ( false );
var sector = new byte [ _targetDisk . SectorSize ];
await sectorStream . ReadAsync ( sector , cancellationToken ). ConfigureAwait ( false );
await _targetDisk . WriteBytesAsync ( 0 , sector , cancellationToken ). ConfigureAwait ( false );
}
catch ( Exception ex )
{
Log . WriteErrorMessage ( LOGTAG , "RestoreDeviceNotWriteable" , ex , $"Failed to write to target device: {_devicePath}. Ensure the device is not in use, is not write-protected, not mounted, and you have sufficient permissions." );
throw ;
}
2026-02-06 06:05:34 +01:00
Log . WriteInformationMessage ( LOGTAG , "RestoreTestSuccess" , $"Successfully opened target device: {_devicePath}, Size: {_targetDisk.Size} bytes, SectorSize: {_targetDisk.SectorSize}" );
await Task . CompletedTask ;
}
/// <inheritdoc />
public Task < bool > CreateFolderIfNotExists ( string path , CancellationToken cancel )
{
2026-02-19 18:56:09 +01:00
// Disk images don't have folders in the traditional sense.
// The "folders" are virtual representations of disks/partitions/filesystems.
2026-02-06 06:05:34 +01:00
return Task . FromResult ( false );
}
/// <inheritdoc />
public Task < bool > FileExists ( string path , CancellationToken cancel )
{
path = NormalizePath ( path );
2026-02-06 12:53:33 +01:00
if ( _pendingWrites . ContainsKey ( path ))
2026-02-06 06:05:34 +01:00
return Task . FromResult ( true );
return Task . FromResult ( false );
}
2026-02-15 12:45:59 +01:00
/// <summary>
/// Parses a partition segment from the path and returns the corresponding partition.
/// The segment is expected to be in the format "part_{PartitionTableType}_{PartitionNumber}", e.g. "part_GPT_1".
/// </summary>
/// <param name="segment">The partition segment string.</param>
/// <returns>The corresponding partition.</returns>
/// <exception cref="InvalidOperationException">Thrown if the partition segment cannot be parsed or the partition is not found.</exception>
2026-02-19 18:16:36 +01:00
internal IPartition ParsePartition ( string segment )
2026-02-06 06:05:34 +01:00
{
2026-02-10 11:05:05 +01:00
// Example segment: "part_GPT_1"
var parts = segment . Split ( '_' );
2026-02-12 13:16:58 +01:00
if ( parts . Length < 3 )
2026-02-10 11:05:05 +01:00
throw new InvalidOperationException ( $"Unable to parse partition information from segment: {segment}. Expected format: part_{{PartitionTableType}}_{{PartitionNumber}}" );
2026-02-12 13:16:58 +01:00
// Parse partition table type (second part)
if (! Enum . TryParse < PartitionTableType >( parts [ 1 ], true , out var ptType ))
throw new InvalidOperationException ( $"Unable to parse partition table type from segment: {segment}. Tried {parts[1]}" );
// Parse partition number (third part)
if (! int . TryParse ( parts [ 2 ], out var pn ))
throw new InvalidOperationException ( $"Unable to parse partition number from segment: {segment}. Tried {parts[2]}" );
// Find the partition in our reconstructed list
var partition = _partitions . FirstOrDefault ( p =>
p . PartitionNumber == pn &&
p . PartitionTable . TableType == ptType );
if ( partition == null )
throw new InvalidOperationException ( $"Partition not found for segment: {segment}. Partition number {pn} with table type {ptType} not in reconstructed partitions." );
return partition ;
2026-02-10 11:05:05 +01:00
}
2026-02-15 12:45:59 +01:00
/// <summary>
/// Parses a filesystem segment from the path and returns the corresponding filesystem.
/// The segment is expected to be in the format "fs_{FileSystemType}", e.g. "fs_NTFS".
/// </summary>
/// <param name="partition">The partition to which the filesystem belongs.</param>
/// <param name="segment">The filesystem segment string.</param>
/// <returns>The corresponding filesystem.</returns>
/// <exception cref="InvalidOperationException">Thrown if the filesystem segment cannot be parsed or the filesystem is not found.</exception>
2026-02-19 18:16:36 +01:00
internal IFilesystem ParseFilesystem ( IPartition partition , string segment )
2026-02-10 11:05:05 +01:00
{
// Example segment: "fs_NTFS"
var parts = segment . Split ( '_' );
2026-02-12 13:16:58 +01:00
if ( parts . Length < 2 )
2026-02-10 11:05:05 +01:00
throw new InvalidOperationException ( $"Unable to parse filesystem information from segment: {segment}. Expected format: fs_{{FileSystemType}}" );
2026-02-12 13:16:58 +01:00
// Reconstruct filesystem type from remaining parts (e.g., "fs_Unknown" or "fs_NTFS")
var fsTypeStr = string . Join ( '_' , parts [ 1. .]);
if (! Enum . TryParse < FileSystemType >( fsTypeStr , true , out var fsType ))
throw new InvalidOperationException ( $"Unable to parse filesystem type from segment: {segment}. Tried {fsTypeStr}" );
// Find the filesystem in our reconstructed list
var fs = _filesystems . FirstOrDefault ( f => f . Partition . PartitionNumber == partition . PartitionNumber && f . Type == fsType );
if ( fs == null )
throw new InvalidOperationException ( $"No matching filesystem found for segment: {segment} with type {fsType}" );
return fs ;
2026-02-10 11:05:05 +01:00
}
2026-02-06 12:53:33 +01:00
2026-02-15 12:45:59 +01:00
/// <summary>
/// Parses the given path to determine if it refers to a disk-level item, partition, or file, and returns the corresponding objects.
/// The path is expected to be in the format:
/// root/part_{PartitionTableType}_{PartitionNumber}/fs_{FileSystemType}/path/to/file
/// </summary>
/// <param name="path">The path to parse.</param>
/// <returns>A tuple containing the item type, partition, and filesystem.</returns>
/// <exception cref="InvalidOperationException">Thrown if the path cannot be parsed.</exception>
2026-02-19 18:16:36 +01:00
internal ( string , IPartition ?, IFilesystem ?) ParsePath ( string path )
2026-02-10 11:05:05 +01:00
{
// For disk image restore, the path is expected to be in the format:
// root/part_{PartitionTableType}_{PartitionNumber}/fs_{FileSystemType}/path/to/file
// We need to parse out the partition and filesystem information from the path for proper handling
2026-02-06 12:53:33 +01:00
2026-02-10 11:05:05 +01:00
// Normalize path separators
path = NormalizePath ( path );
2026-02-13 16:43:15 +01:00
2026-03-11 14:58:23 +00:00
// On Windows, both / and \ are path separators. On other platforms, only / is.
var separators = OperatingSystem . IsWindows () ? new [] { '/' , '\\' } : new [] { '/' };
2026-02-13 16:43:15 +01:00
if ( IsGeometryFile ( path ))
return ( "geometry" , null , null );
2026-03-11 14:58:23 +00:00
var segments = path . Split ( separators , StringSplitOptions . RemoveEmptyEntries ) ??
2026-02-12 13:16:58 +01:00
throw new InvalidOperationException ( $"Unable to parse path: {path}" );
2026-02-13 16:43:15 +01:00
2026-02-09 12:28:40 +01:00
2026-02-10 11:05:05 +01:00
string? partitionSegment = segments . FirstOrDefault ( s => s . StartsWith ( "part_" , StringComparison . OrdinalIgnoreCase ));
if (! string . IsNullOrEmpty ( partitionSegment ))
2026-02-06 12:53:33 +01:00
{
2026-02-10 11:05:05 +01:00
var partition = ParsePartition ( partitionSegment );
string? filesystemSegment = segments . FirstOrDefault ( s => s . StartsWith ( "fs_" , StringComparison . OrdinalIgnoreCase ));
if (! string . IsNullOrEmpty ( filesystemSegment ))
{
2026-02-12 13:16:58 +01:00
var filesystem = ParseFilesystem ( partition , filesystemSegment );
2026-02-10 11:05:05 +01:00
return ( "file" , partition , filesystem );
}
return ( "partition" , partition , null );
}
return ( "disk" , null , null );
2026-02-06 06:05:34 +01:00
}
2026-02-10 11:05:05 +01:00
/// <inheritdoc />
public Task < Stream > OpenWrite ( string path , CancellationToken cancel )
2026-02-06 06:05:34 +01:00
{
2026-02-10 11:05:05 +01:00
var ( typeStr , partition , filesystem ) = ParsePath ( path );
2026-02-06 12:53:33 +01:00
2026-02-10 11:05:05 +01:00
return typeStr switch
2026-02-06 12:53:33 +01:00
{
2026-02-10 11:05:05 +01:00
"geometry" => OpenWriteGeometry ( cancel ),
"disk" => OpenWriteDisk ( path , cancel ),
"partition" => OpenWritePartition ( path , partition !, cancel ),
"file" => filesystem !. OpenWriteStreamAsync ( path , cancel ),
_ => throw new NotSupportedException ( $"Unsupported item type: {typeStr}" )
};
2026-02-06 12:53:33 +01:00
}
/// <summary>
/// Opens a stream for writing disk-level data (stored in memory until Finalize).
/// </summary>
2026-02-10 11:05:05 +01:00
private Task < Stream > OpenWriteDisk ( string path , CancellationToken cancel )
2026-02-06 12:53:33 +01:00
{
var stream = new MemoryStream ();
var wrapper = new CaptureStream ( stream , data =>
{
2026-02-10 11:05:05 +01:00
var pendingWrite = new DiskPendingWrite ();
2026-02-06 12:53:33 +01:00
_pendingWrites . AddOrUpdate ( path , pendingWrite , ( _ , old ) =>
{
old . Dispose ();
return pendingWrite ;
});
});
return Task . FromResult < Stream >( wrapper );
}
/// <summary>
/// Opens a stream for writing partition data (stored in memory until Finalize).
/// </summary>
2026-02-10 11:05:05 +01:00
private Task < Stream > OpenWritePartition ( string path , IPartition partition , CancellationToken cancel )
2026-02-06 12:53:33 +01:00
{
var stream = new MemoryStream ();
var wrapper = new CaptureStream ( stream , data =>
{
2026-02-10 11:05:05 +01:00
var pendingWrite = new PartitionPendingWrite ( partition );
2026-02-06 12:53:33 +01:00
_pendingWrites . AddOrUpdate ( path , pendingWrite , ( _ , old ) =>
{
old . Dispose ();
return pendingWrite ;
});
});
return Task . FromResult < Stream >( wrapper );
}
2026-02-09 11:36:15 +01:00
/// <summary>
/// Opens a stream for writing geometry metadata (stored in memory until Finalize).
/// </summary>
2026-02-10 11:05:05 +01:00
private Task < Stream > OpenWriteGeometry ( CancellationToken cancel )
2026-02-09 11:36:15 +01:00
{
var stream = new MemoryStream ();
var wrapper = new CaptureStream ( stream , data =>
{
try
{
// Parse the geometry metadata from the JSON data
2026-02-15 13:42:49 +01:00
var json = System . Text . Encoding . UTF8 . GetString ( data . Span );
2026-02-10 11:05:05 +01:00
_geometryMetadata = GeometryMetadata . FromJson ( json );
2026-02-09 11:36:15 +01:00
2026-02-10 11:05:05 +01:00
Log . WriteInformationMessage ( LOGTAG , "GeometryMetadataParsed" , "Successfully parsed geometry metadata from geometry.json during OpenWrite" );
2026-02-09 11:36:15 +01:00
}
catch ( Exception ex )
{
Log . WriteWarningMessage ( LOGTAG , "GeometryMetadataParseFailed" , ex ,
2026-02-10 11:05:05 +01:00
"Failed to parse geometry metadata from geometry.json during OpenWrite" );
2026-02-09 11:36:15 +01:00
}
});
return Task . FromResult < Stream >( wrapper );
}
2026-02-06 06:05:34 +01:00
/// <inheritdoc />
2026-02-10 11:05:05 +01:00
public Task < Stream > OpenRead ( string path , CancellationToken cancel )
2026-02-06 06:05:34 +01:00
{
2026-02-10 11:05:05 +01:00
var ( typeStr , partition , filesystem ) = ParsePath ( path );
2026-02-09 15:12:34 +01:00
2026-02-06 13:32:30 +01:00
return typeStr switch
{
2026-02-12 13:16:58 +01:00
"disk" => OpenReadDisk ( path , cancel ),
"partition" => OpenReadPartition ( path , partition !, cancel ),
2026-02-10 11:05:05 +01:00
"geometry" => OpenReadGeometry ( cancel ),
"file" => filesystem !. OpenReadStreamAsync ( path , cancel ),
_ => throw new NotSupportedException ( $"Unsupported item type: {typeStr}" )
2026-02-06 13:32:30 +01:00
};
}
2026-02-09 15:12:34 +01:00
/// <summary>
/// Opens a stream for reading geometry metadata.
/// </summary>
2026-02-10 11:05:05 +01:00
private Task < Stream > OpenReadGeometry ( CancellationToken cancel )
2026-02-09 15:12:34 +01:00
{
2026-02-10 11:05:05 +01:00
if ( _geometryMetadata == null )
throw new InvalidOperationException ( "Geometry metadata not available for reading." );
2026-02-09 15:12:34 +01:00
2026-02-10 11:05:05 +01:00
var json = _geometryMetadata . ToJson ();
var data = System . Text . Encoding . UTF8 . GetBytes ( json );
return Task . FromResult < Stream >( new MemoryStream ( data ));
2026-02-06 13:32:30 +01:00
}
/// <summary>
/// Opens a stream for reading disk-level data.
/// </summary>
2026-02-10 11:05:05 +01:00
private Task < Stream > OpenReadDisk ( string path , CancellationToken cancel )
2026-02-06 13:32:30 +01:00
{
if ( _targetDisk == null )
throw new InvalidOperationException ( "Target disk not initialized." );
2026-02-10 11:05:05 +01:00
throw new InvalidOperationException ( "Reading raw disk data as part of the restore flow is currently not supported in this implementation." );
2026-02-06 13:32:30 +01:00
}
/// <summary>
/// Opens a stream for reading partition data.
/// </summary>
2026-02-10 11:05:05 +01:00
private Task < Stream > OpenReadPartition ( string path , IPartition partition , CancellationToken cancel )
2026-02-06 13:32:30 +01:00
{
if ( _targetDisk == null )
throw new InvalidOperationException ( "Target disk not initialized." );
2026-02-10 11:05:05 +01:00
throw new InvalidOperationException ( "Reading raw partition data as part of the restore flow is currently not supported in this implementation." );
2026-02-06 12:53:33 +01:00
}
2026-02-06 06:05:34 +01:00
2026-02-06 12:53:33 +01:00
/// <inheritdoc />
2026-02-10 11:05:05 +01:00
public Task < Stream > OpenReadWrite ( string path , CancellationToken cancel )
2026-02-06 13:32:30 +01:00
{
2026-02-10 11:05:05 +01:00
var ( typeStr , partition , filesystem ) = ParsePath ( path );
2026-02-06 13:32:30 +01:00
2026-02-10 11:05:05 +01:00
return typeStr switch
2026-02-06 13:32:30 +01:00
{
2026-02-13 06:58:23 +01:00
"disk" => OpenWriteDisk ( path , cancel ), // For disk-level, we treat read-write as write since we only capture the data to be written during Finalize
2026-02-12 13:16:58 +01:00
"partition" => Task . FromResult (( Stream ) new MemoryStream ()),
2026-02-10 11:05:05 +01:00
"geometry" => OpenReadWriteGeometry ( cancel ),
"file" => filesystem !. OpenReadWriteStreamAsync ( path , cancel ),
_ => throw new NotSupportedException ( $"Unsupported item type: {typeStr}" )
};
2026-02-06 13:32:30 +01:00
}
2026-02-09 15:12:34 +01:00
/// <summary>
/// Opens a stream for read-write access to geometry metadata.
/// </summary>
2026-02-15 12:45:59 +01:00
/// <param name="cancel">Cancellation token.</param>
/// <returns>A stream for read-write access to geometry metadata.</returns>
2026-02-10 11:05:05 +01:00
private async Task < Stream > OpenReadWriteGeometry ( CancellationToken cancel )
2026-02-09 15:12:34 +01:00
{
// For read-write, we return a stream that can be read from (current state)
// and written to (updating the state).
var currentData = Array . Empty < byte >();
2026-02-10 11:05:05 +01:00
if ( _geometryMetadata != null )
2026-02-09 15:12:34 +01:00
{
2026-02-10 11:05:05 +01:00
var json = _geometryMetadata . ToJson ();
2026-02-09 15:12:34 +01:00
currentData = System . Text . Encoding . UTF8 . GetBytes ( json );
}
var stream = new MemoryStream ();
if ( currentData . Length > 0 )
{
2026-02-10 11:05:05 +01:00
await stream . WriteAsync ( currentData , cancel );
2026-02-09 15:12:34 +01:00
stream . Position = 0 ;
}
2026-02-13 06:58:23 +01:00
var wrapper = new CaptureStream ( stream , async data =>
2026-02-09 15:12:34 +01:00
{
try
{
2026-02-15 13:42:49 +01:00
var json = System . Text . Encoding . UTF8 . GetString ( data . Span );
2026-02-09 15:12:34 +01:00
var newGeometry = GeometryMetadata . FromJson ( json );
if ( newGeometry != null )
{
2026-02-10 11:05:05 +01:00
_geometryMetadata = newGeometry ;
2026-02-12 13:16:58 +01:00
// Clear existing reconstructed objects
foreach ( var part in _partitions )
part . Dispose ();
_partitions . Clear ();
foreach ( var fs in _filesystems )
fs . Dispose ();
_filesystems . Clear ();
// Reconstruct disk, partition table, partitions, and filesystems from geometry metadata
ReconstructFromGeometryMetadata ();
2026-02-13 06:58:23 +01:00
using var _ = await OpenWriteDisk ( "disk" , cancel ); // Mark disk-level data as pending write for Finalize
2026-02-12 13:16:58 +01:00
Log . WriteInformationMessage ( LOGTAG , "GeometryMetadataUpdated" , $"Successfully updated geometry metadata during ReadWrite. Reconstructed {_partitions.Count} partitions and {_filesystems.Count} filesystems." );
2026-02-10 11:05:05 +01:00
}
else
{
Log . WriteWarningMessage ( LOGTAG , "GeometryMetadataUpdateFailed" , null , $"Failed to parse geometry metadata during ReadWrite. Parsed object was null." );
2026-02-09 15:12:34 +01:00
}
}
catch ( Exception ex )
{
Log . WriteWarningMessage ( LOGTAG , "GeometryMetadataParseFailed" , ex ,
2026-02-10 11:05:05 +01:00
$"Failed to parse geometry metadata during ReadWrite" );
2026-02-09 15:12:34 +01:00
}
});
return wrapper ;
}
2026-02-15 12:45:59 +01:00
/// <summary>
/// Gets the length of a file at the specified path.
/// </summary>
/// <param name="path">The path to the file.</param>
/// <param name="cancel">Cancellation token.</param>
/// <returns>The file length in bytes.</returns>
2026-02-06 06:05:34 +01:00
public Task < long > GetFileLength ( string path , CancellationToken cancel )
{
2026-02-10 11:05:05 +01:00
var ( typeStr , partition , filesystem ) = ParsePath ( path );
2026-02-06 06:05:34 +01:00
2026-02-10 11:05:05 +01:00
return typeStr switch
2026-02-06 12:53:33 +01:00
{
2026-02-12 13:16:58 +01:00
"disk" => Task . FromResult ( 0L ),
"partition" => Task . FromResult ( 0L ),
2026-02-10 11:05:05 +01:00
"geometry" => Task . FromResult (( long ) _geometryMetadata !. ToJson (). Count ()),
"file" => filesystem !. GetFileLengthAsync ( path , cancel ),
_ => throw new NotSupportedException ( $"Unsupported item type: {typeStr}" )
};
2026-02-06 06:05:34 +01:00
}
/// <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 )
{
2026-02-10 11:05:05 +01:00
// TODO properly handle metadata
2026-02-06 06:05:34 +01:00
return Task . FromResult ( true );
}
/// <inheritdoc />
public Task DeleteFolder ( string path , CancellationToken cancel )
=> Task . CompletedTask ;
/// <inheritdoc />
public Task DeleteFile ( string path , CancellationToken cancel )
=> Task . CompletedTask ;
2026-02-09 11:36:15 +01:00
/// <inheritdoc />
public IList < string > GetPriorityFiles ()
{
2026-02-09 15:12:14 +01:00
return [ "geometry.json" ];
2026-02-09 12:28:40 +01:00
}
/// <summary>
/// Checks if a file path is the geometry metadata file.
/// </summary>
2026-02-15 12:45:59 +01:00
/// <param name="path">The file path to check.</param>
/// <returns><c>true</c> if the path is the geometry file; otherwise, <c>false</c>.</returns>
2026-02-09 12:28:40 +01:00
private static bool IsGeometryFile ( string path )
{
2026-02-13 16:43:15 +01:00
// Check for geometry.json (must be at root or top level)
// Valid paths: "geometry.json", "root/geometry.json"
if (! path . EndsWith ( "geometry.json" , StringComparison . OrdinalIgnoreCase ))
return false ;
// TODO only check the last path for now.
return true ;
// Normalize path separators
var normalized = path . Replace ( '\\' , '/' ). TrimStart ( '/' );
var segments = normalized . Split ( '/' , StringSplitOptions . RemoveEmptyEntries );
// Should be at most 2 segments (e.g. "root/geometry.json")
return segments . Length <= 2 ;
2026-02-09 11:36:15 +01:00
}
2026-02-06 06:05:34 +01:00
/// <inheritdoc />
public async Task Finalize ( Action < double >? progressCallback , CancellationToken cancel )
{
if ( _targetDisk == null )
throw new InvalidOperationException ( "Provider not initialized." );
2026-02-10 11:05:05 +01:00
var totalItems = _pendingWrites . Count ;
2026-02-06 06:05:34 +01:00
if ( totalItems == 0 )
{
Log . WriteInformationMessage ( LOGTAG , "RestoreNoItems" , "No items to restore." );
return ;
}
2026-02-10 11:05:05 +01:00
Log . WriteInformationMessage ( LOGTAG , "RestoreStarting" , $"Starting final restore of {totalItems} items to {_devicePath}" );
2026-02-06 06:05:34 +01:00
var processedCount = 0 ;
// Group items by type for ordered restoration
2026-02-10 11:05:05 +01:00
var diskItems = _pendingWrites . Where ( kv => kv . Value is DiskPendingWrite ). ToList ();
var partitionItems = _pendingWrites . Where ( kv => kv . Value is PartitionPendingWrite ). ToList ();
2026-02-06 06:39:22 +01:00
2026-02-13 06:58:23 +01:00
// Restore disk-level items (partition table)
2026-02-06 06:05:34 +01:00
if (! _skipPartitionTable && diskItems . Count > 0 )
{
2026-02-13 06:58:23 +01:00
if ( _geometryMetadata ?. PartitionTable != null )
{
try
{
2026-02-15 16:29:40 +01:00
var partitionTableData = PartitionTableSynthesizer . SynthesizePartitionTable ( _geometryMetadata );
2026-02-13 06:58:23 +01:00
if ( partitionTableData != null )
{
// Write primary partition table at the start of the disk
await _targetDisk . WriteBytesAsync ( 0 , partitionTableData , cancel ). ConfigureAwait ( false );
Log . WriteInformationMessage ( LOGTAG , "PartitionTableWritten" ,
$"Successfully wrote {_geometryMetadata.PartitionTable.Type} partition table to disk." );
// For GPT, also write the secondary GPT header at the end of the disk
if ( _geometryMetadata . PartitionTable . Type == PartitionTableType . GPT )
{
2026-02-15 16:29:40 +01:00
await PartitionTableSynthesizer . WriteSecondaryGPTAsync ( _targetDisk , _geometryMetadata , partitionTableData , cancel ). ConfigureAwait ( false );
Log . WriteInformationMessage ( LOGTAG , "SecondaryGPTWritten" ,
"Successfully wrote secondary GPT header and partition entries." );
2026-02-13 06:58:23 +01:00
}
}
}
catch ( Exception ex )
{
Log . WriteErrorMessage ( LOGTAG , "PartitionTableWriteFailed" , ex ,
$"Failed to write partition table to disk: {ex.Message}" );
throw ;
}
}
else
{
Log . WriteWarningMessage ( LOGTAG , "NoPartitionTableMetadata" , null ,
"Disk-level items pending but no partition table metadata available to write." );
}
2026-02-06 06:05:34 +01:00
processedCount += diskItems . Count ;
progressCallback ?. Invoke ( processedCount / ( double ) totalItems );
}
// Restore partition-level items
if ( partitionItems . Count > 0 )
{
2026-02-19 18:58:11 +01:00
// Currently a NOP operation. If a partition needs to restore
// specific data during restore, it's here.
2026-02-06 06:05:34 +01:00
processedCount += partitionItems . Count ;
progressCallback ?. Invoke ( processedCount / ( double ) totalItems );
}
2026-02-06 12:53:33 +01:00
// Cleanup
foreach ( var pendingWrite in _pendingWrites . Values )
pendingWrite . Dispose ();
_pendingWrites . Clear ();
2026-02-06 06:05:34 +01:00
Log . WriteInformationMessage ( LOGTAG , "RestoreComplete" , "Restore operation completed." );
}
/// <summary>
/// Normalizes the given path.
/// </summary>
2026-02-15 12:45:59 +01:00
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
2026-02-06 06:05:34 +01:00
private string NormalizePath ( string path )
{
// Remove any leading/trailing separators and normalize
return path . TrimStart ( '/' , '\\' ). TrimEnd ( '/' , '\\' );
}
/// <summary>
/// Disposes the restore provider.
/// </summary>
public void Dispose ()
{
if ( _disposed ) return ;
_targetDisk ?. Dispose ();
2026-02-06 12:53:33 +01:00
foreach ( var pendingWrite in _pendingWrites . Values )
pendingWrite . Dispose ();
_pendingWrites . Clear ();
2026-02-06 06:05:34 +01:00
_disposed = true ;
}
2026-02-06 12:53:33 +01:00
2026-02-12 13:16:58 +01:00
/// <summary>
/// Reconstructs IRawDisk, IPartitionTable, IPartition, and IFilesystem objects
/// from the geometry metadata. This is called when geometry.json is written during restore.
/// </summary>
2026-02-15 12:45:59 +01:00
/// <exception cref="InvalidOperationException">Thrown if geometry metadata or target disk is not available.</exception>
2026-02-12 13:16:58 +01:00
private void ReconstructFromGeometryMetadata ()
{
if ( _geometryMetadata == null )
throw new InvalidOperationException ( "Geometry metadata is not available for reconstruction." );
if ( _targetDisk == null )
throw new InvalidOperationException ( "Target disk is not initialized." );
// Create reconstructed partition table based on metadata
IPartitionTable ? partitionTable = null ;
if ( _geometryMetadata . PartitionTable != null )
{
partitionTable = _geometryMetadata . PartitionTable . Type switch
{
2026-02-15 15:58:30 +01:00
PartitionTableType . GPT => new ReconstructedPartitionTable ( _targetDisk , _geometryMetadata , PartitionTableType . GPT ),
PartitionTableType . MBR => new ReconstructedPartitionTable ( _targetDisk , _geometryMetadata , PartitionTableType . MBR ),
2026-02-15 10:03:18 +01:00
PartitionTableType . Unknown => new UnknownPartitionTable ( _targetDisk ),
2026-02-12 13:16:58 +01:00
_ => null
};
}
// Reconstruct partitions from metadata
if ( _geometryMetadata . Partitions != null && partitionTable != null )
{
foreach ( var partGeom in _geometryMetadata . Partitions )
{
2026-02-15 15:22:54 +01:00
var partition = new BasePartition
{
PartitionNumber = partGeom . Number ,
Type = partGeom . Type ,
PartitionTable = partitionTable ,
StartOffset = partGeom . StartOffset ,
Size = partGeom . Size ,
Name = partGeom . Name ,
FilesystemType = partGeom . FilesystemType ,
VolumeGuid = partGeom . VolumeGuid ,
RawDisk = _targetDisk ,
StartingLba = 0 ,
EndingLba = 0 ,
Attributes = 0
};
2026-02-12 13:16:58 +01:00
_partitions . Add ( partition );
}
}
// Reconstruct filesystems from metadata
if ( _geometryMetadata . Filesystems != null )
{
foreach ( var fsGeom in _geometryMetadata . Filesystems )
{
// Find the corresponding partition for this filesystem
var partition = _partitions . FirstOrDefault ( p => p . PartitionNumber == fsGeom . PartitionNumber );
if ( partition != null )
{
var filesystem = CreateFilesystemFromGeometry ( partition , fsGeom );
if ( filesystem != null )
_filesystems . Add ( filesystem );
}
}
}
}
/// <summary>
/// Creates an IFilesystem instance from filesystem geometry metadata.
/// </summary>
2026-02-15 12:45:59 +01:00
/// <param name="partition">The partition to create the filesystem for.</param>
/// <param name="fsGeom">The filesystem geometry metadata.</param>
/// <returns>An IFilesystem instance, or null if the filesystem type is not supported.</returns>
2026-02-12 13:16:58 +01:00
private IFilesystem ? CreateFilesystemFromGeometry ( IPartition partition , FilesystemGeometry fsGeom )
{
return fsGeom . Type switch
{
// For now, we use UnknownFilesystem as the base implementation
// Specific filesystem implementations can be added later
_ => new UnknownFilesystem ( partition , fsGeom . BlockSize )
};
}
2026-02-06 12:53:33 +01:00
/// <summary>
/// A stream that captures the written data when disposed and invokes a callback.
/// </summary>
private class CaptureStream : Stream
{
private readonly MemoryStream _innerStream ;
2026-02-15 13:42:49 +01:00
private readonly Action < ReadOnlyMemory < byte >> _onCaptured ;
2026-02-06 12:53:33 +01:00
private bool _disposed = false ;
2026-02-15 13:42:49 +01:00
public CaptureStream ( MemoryStream innerStream , Action < ReadOnlyMemory < byte >> onCaptured )
2026-02-06 12:53:33 +01:00
{
_innerStream = innerStream ;
_onCaptured = onCaptured ;
}
public override bool CanRead => _innerStream . CanRead ;
public override bool CanSeek => _innerStream . CanSeek ;
public override bool CanWrite => _innerStream . CanWrite ;
public override long Length => _innerStream . Length ;
public override long Position
{
get => _innerStream . Position ;
set => _innerStream . Position = value ;
}
public override void Flush () => _innerStream . Flush ();
public override int Read ( byte [] buffer , int offset , int count ) => _innerStream . Read ( buffer , offset , count );
public override long Seek ( long offset , SeekOrigin origin ) => _innerStream . Seek ( offset , origin );
public override void SetLength ( long value ) => _innerStream . SetLength ( value );
public override void Write ( byte [] buffer , int offset , int count ) => _innerStream . Write ( buffer , offset , count );
protected override void Dispose ( bool disposing )
{
if (! _disposed )
{
if ( disposing )
{
// Capture the data before disposing
_innerStream . Position = 0 ;
2026-02-15 13:42:49 +01:00
var data = _innerStream . GetBuffer (). AsMemory ( 0 , ( int ) _innerStream . Length );
2026-02-06 12:53:33 +01:00
_onCaptured ( data );
_innerStream . Dispose ();
}
_disposed = true ;
}
base . Dispose ( disposing );
}
}
2026-02-06 06:05:34 +01:00
}