// 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; using Duplicati.Proprietary.DiskImage.Filesystem; using Duplicati.Proprietary.DiskImage.General; using Duplicati.Proprietary.DiskImage.Partition; namespace Duplicati.Proprietary.DiskImage; /// /// Restore provider for disk images. Allows restoring disk images back to physical disks. /// public sealed class RestoreProvider : IRestoreDestinationProviderModule, IDisposable { private static readonly string LOGTAG = Log.LogTagFromType(); private readonly string _devicePath; private readonly string _restorePath; private readonly bool _autoUnmount; private readonly bool _skipPartitionTable; private readonly bool _validateSize; private readonly bool _hasSetOverwriteOption; private IRawDisk? _targetDisk; private bool _disposed; /// /// Tracks pending writes for items that need to be written during Finalize. /// For partition table items, this stores the data to be written. /// private readonly ConcurrentDictionary _pendingWrites = new(); /// /// Stores geometry metadata parsed from restored geometry files. /// Used to reconstruct disk, partition, and filesystem structures. /// private GeometryMetadata? _geometryMetadata; private List _partitions = []; private List _filesystems = []; /// /// Represents a pending write operation. /// private abstract class PendingWrite : IDisposable { public abstract void Dispose(); } /// /// Pending write for disk-level data (stored in memory until Finalize). /// private class DiskPendingWrite : PendingWrite { // Empty class, as this is used for tracking whether we have to write // disk-level data (e.g. partition table) during Finalize. public override void Dispose() { // Nothing to dispose } } /// /// Pending write for partition data (stored in memory until Finalize). /// private class PartitionPendingWrite(IPartition Partition) : PendingWrite { // 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; // 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. public override void Dispose() { // Nothing to dispose } } /// /// Default constructor for the restore provider. /// Only used for loading metadata about the provider. /// public RestoreProvider() { _devicePath = null!; _restorePath = null!; _skipPartitionTable = false; _validateSize = true; _hasSetOverwriteOption = false; } /// /// Constructs the RestoreProvider with the given URL and options. /// /// The destination URL for the restore operation /// The options for the restore operation public RestoreProvider(string url, Dictionary options) { var uri = new Library.Utility.Uri(url); _restorePath = uri.HostAndPath; _devicePath = uri.HostAndPath; _skipPartitionTable = Utility.ParseBoolOption(options, OptionsHelper.DISK_RESTORE_SKIP_PARTITION_TABLE_OPTION); _validateSize = Utility.ParseBoolOption(options, OptionsHelper.DISK_RESTORE_VALIDATE_SIZE_OPTION); _autoUnmount = Utility.ParseBoolOption(options, OptionsHelper.DISK_RESTORE_AUTO_UNMOUNT_OPTION); _hasSetOverwriteOption = Utility.ParseBoolOption(options, "overwrite"); } /// public string Key => OptionsHelper.ModuleKey; /// public string DisplayName => Strings.RestoreProviderDisplayName; /// public string Description => Strings.RestoreProviderDescription; /// public IList SupportedCommands => OptionsHelper.SupportedCommands; /// public string TargetDestination => _restorePath; /// public async Task Initialize(CancellationToken cancel) { if (string.IsNullOrEmpty(_devicePath)) throw new UserInformationException("Disk device path is not specified.", "DiskDeviceNotSpecified"); if (OperatingSystem.IsWindows()) _targetDisk = new Windows(_devicePath); else if (OperatingSystem.IsMacOS()) _targetDisk = new Mac(_devicePath); else if (OperatingSystem.IsLinux()) _targetDisk = new Linux(_devicePath); else throw new PlatformNotSupportedException(Strings.PlatformNotSupported); 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"); // 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."); } } /// public async Task Test(CancellationToken cancellationToken) { if (_targetDisk == null) throw new InvalidOperationException("Provider not initialized."); 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; } Log.WriteInformationMessage(LOGTAG, "RestoreTestSuccess", $"Successfully opened target device: {_devicePath}, Size: {_targetDisk.Size} bytes, SectorSize: {_targetDisk.SectorSize}"); await Task.CompletedTask; } /// public Task CreateFolderIfNotExists(string path, CancellationToken cancel) { // Disk images don't have folders in the traditional sense. // The "folders" are virtual representations of disks/partitions/filesystems. return Task.FromResult(false); } /// public Task FileExists(string path, CancellationToken cancel) { path = NormalizePath(path); if (_pendingWrites.ContainsKey(path)) return Task.FromResult(true); return Task.FromResult(false); } /// /// 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". /// /// The partition segment string. /// The corresponding partition. /// Thrown if the partition segment cannot be parsed or the partition is not found. internal IPartition ParsePartition(string segment) { // Example segment: "part_GPT_1" var parts = segment.Split('_'); if (parts.Length < 3) throw new InvalidOperationException($"Unable to parse partition information from segment: {segment}. Expected format: part_{{PartitionTableType}}_{{PartitionNumber}}"); // Parse partition table type (second part) if (!Enum.TryParse(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; } /// /// 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". /// /// The partition to which the filesystem belongs. /// The filesystem segment string. /// The corresponding filesystem. /// Thrown if the filesystem segment cannot be parsed or the filesystem is not found. internal IFilesystem ParseFilesystem(IPartition partition, string segment) { // Example segment: "fs_NTFS" var parts = segment.Split('_'); if (parts.Length < 2) throw new InvalidOperationException($"Unable to parse filesystem information from segment: {segment}. Expected format: fs_{{FileSystemType}}"); // Reconstruct filesystem type from remaining parts (e.g., "fs_Unknown" or "fs_NTFS") var fsTypeStr = string.Join('_', parts[1..]); if (!Enum.TryParse(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; } /// /// 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 /// /// The path to parse. /// A tuple containing the item type, partition, and filesystem. /// Thrown if the path cannot be parsed. internal (string, IPartition?, IFilesystem?) ParsePath(string path) { // 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 // Normalize path separators path = NormalizePath(path); // On Windows, both / and \ are path separators. On other platforms, only / is. var separators = OperatingSystem.IsWindows() ? new[] { '/', '\\' } : new[] { '/' }; if (IsGeometryFile(path)) return ("geometry", null, null); var segments = path.Split(separators, StringSplitOptions.RemoveEmptyEntries) ?? throw new InvalidOperationException($"Unable to parse path: {path}"); string? partitionSegment = segments.FirstOrDefault(s => s.StartsWith("part_", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrEmpty(partitionSegment)) { var partition = ParsePartition(partitionSegment); string? filesystemSegment = segments.FirstOrDefault(s => s.StartsWith("fs_", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrEmpty(filesystemSegment)) { var filesystem = ParseFilesystem(partition, filesystemSegment); return ("file", partition, filesystem); } return ("partition", partition, null); } return ("disk", null, null); } /// public Task OpenWrite(string path, CancellationToken cancel) { var (typeStr, partition, filesystem) = ParsePath(path); return typeStr switch { "geometry" => OpenWriteGeometry(cancel), "disk" => OpenWriteDisk(path, cancel), "partition" => OpenWritePartition(path, partition!, cancel), "file" => filesystem!.OpenWriteStreamAsync(path, cancel), _ => throw new NotSupportedException($"Unsupported item type: {typeStr}") }; } /// /// Opens a stream for writing disk-level data (stored in memory until Finalize). /// private Task OpenWriteDisk(string path, CancellationToken cancel) { var stream = new MemoryStream(); var wrapper = new CaptureStream(stream, data => { var pendingWrite = new DiskPendingWrite(); _pendingWrites.AddOrUpdate(path, pendingWrite, (_, old) => { old.Dispose(); return pendingWrite; }); }); return Task.FromResult(wrapper); } /// /// Opens a stream for writing partition data (stored in memory until Finalize). /// private Task OpenWritePartition(string path, IPartition partition, CancellationToken cancel) { var stream = new MemoryStream(); var wrapper = new CaptureStream(stream, data => { var pendingWrite = new PartitionPendingWrite(partition); _pendingWrites.AddOrUpdate(path, pendingWrite, (_, old) => { old.Dispose(); return pendingWrite; }); }); return Task.FromResult(wrapper); } /// /// Opens a stream for writing geometry metadata (stored in memory until Finalize). /// private Task OpenWriteGeometry(CancellationToken cancel) { var stream = new MemoryStream(); var wrapper = new CaptureStream(stream, data => { try { // Parse the geometry metadata from the JSON data var json = System.Text.Encoding.UTF8.GetString(data.Span); _geometryMetadata = GeometryMetadata.FromJson(json); Log.WriteInformationMessage(LOGTAG, "GeometryMetadataParsed", "Successfully parsed geometry metadata from geometry.json during OpenWrite"); } catch (Exception ex) { Log.WriteWarningMessage(LOGTAG, "GeometryMetadataParseFailed", ex, "Failed to parse geometry metadata from geometry.json during OpenWrite"); } }); return Task.FromResult(wrapper); } /// public Task OpenRead(string path, CancellationToken cancel) { var (typeStr, partition, filesystem) = ParsePath(path); return typeStr switch { "disk" => OpenReadDisk(path, cancel), "partition" => OpenReadPartition(path, partition!, cancel), "geometry" => OpenReadGeometry(cancel), "file" => filesystem!.OpenReadStreamAsync(path, cancel), _ => throw new NotSupportedException($"Unsupported item type: {typeStr}") }; } /// /// Opens a stream for reading geometry metadata. /// private Task OpenReadGeometry(CancellationToken cancel) { if (_geometryMetadata == null) throw new InvalidOperationException("Geometry metadata not available for reading."); var json = _geometryMetadata.ToJson(); var data = System.Text.Encoding.UTF8.GetBytes(json); return Task.FromResult(new MemoryStream(data)); } /// /// Opens a stream for reading disk-level data. /// private Task OpenReadDisk(string path, CancellationToken cancel) { if (_targetDisk == null) throw new InvalidOperationException("Target disk not initialized."); throw new InvalidOperationException("Reading raw disk data as part of the restore flow is currently not supported in this implementation."); } /// /// Opens a stream for reading partition data. /// private Task OpenReadPartition(string path, IPartition partition, CancellationToken cancel) { if (_targetDisk == null) throw new InvalidOperationException("Target disk not initialized."); throw new InvalidOperationException("Reading raw partition data as part of the restore flow is currently not supported in this implementation."); } /// public Task OpenReadWrite(string path, CancellationToken cancel) { var (typeStr, partition, filesystem) = ParsePath(path); return typeStr switch { "disk" => OpenWriteDisk(path, cancel), // For disk-level, we treat read-write as write since we only capture the data to be written during Finalize "partition" => Task.FromResult((Stream)new MemoryStream()), "geometry" => OpenReadWriteGeometry(cancel), "file" => filesystem!.OpenReadWriteStreamAsync(path, cancel), _ => throw new NotSupportedException($"Unsupported item type: {typeStr}") }; } /// /// Opens a stream for read-write access to geometry metadata. /// /// Cancellation token. /// A stream for read-write access to geometry metadata. private async Task OpenReadWriteGeometry(CancellationToken cancel) { // For read-write, we return a stream that can be read from (current state) // and written to (updating the state). var currentData = Array.Empty(); if (_geometryMetadata != null) { var json = _geometryMetadata.ToJson(); currentData = System.Text.Encoding.UTF8.GetBytes(json); } var stream = new MemoryStream(); if (currentData.Length > 0) { await stream.WriteAsync(currentData, cancel); stream.Position = 0; } var wrapper = new CaptureStream(stream, async data => { try { var json = System.Text.Encoding.UTF8.GetString(data.Span); var newGeometry = GeometryMetadata.FromJson(json); if (newGeometry != null) { _geometryMetadata = newGeometry; // 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(); using var _ = await OpenWriteDisk("disk", cancel); // Mark disk-level data as pending write for Finalize Log.WriteInformationMessage(LOGTAG, "GeometryMetadataUpdated", $"Successfully updated geometry metadata during ReadWrite. Reconstructed {_partitions.Count} partitions and {_filesystems.Count} filesystems."); } else { Log.WriteWarningMessage(LOGTAG, "GeometryMetadataUpdateFailed", null, $"Failed to parse geometry metadata during ReadWrite. Parsed object was null."); } } catch (Exception ex) { Log.WriteWarningMessage(LOGTAG, "GeometryMetadataParseFailed", ex, $"Failed to parse geometry metadata during ReadWrite"); } }); return wrapper; } /// /// Gets the length of a file at the specified path. /// /// The path to the file. /// Cancellation token. /// The file length in bytes. public Task GetFileLength(string path, CancellationToken cancel) { var (typeStr, partition, filesystem) = ParsePath(path); return typeStr switch { "disk" => Task.FromResult(0L), "partition" => Task.FromResult(0L), "geometry" => Task.FromResult((long)_geometryMetadata!.ToJson().Count()), "file" => filesystem!.GetFileLengthAsync(path, cancel), _ => throw new NotSupportedException($"Unsupported item type: {typeStr}") }; } /// public Task HasReadOnlyAttribute(string path, CancellationToken cancel) => Task.FromResult(false); /// public Task ClearReadOnlyAttribute(string path, CancellationToken cancel) => Task.CompletedTask; /// public Task WriteMetadata(string path, Dictionary metadata, bool restoreSymlinkMetadata, bool restorePermissions, CancellationToken cancel) { // TODO properly handle metadata return Task.FromResult(true); } /// public Task DeleteFolder(string path, CancellationToken cancel) => Task.CompletedTask; /// public Task DeleteFile(string path, CancellationToken cancel) => Task.CompletedTask; /// public IList GetPriorityFiles() { return ["geometry.json"]; } /// /// Checks if a file path is the geometry metadata file. /// /// The file path to check. /// true if the path is the geometry file; otherwise, false. private static bool IsGeometryFile(string path) { // 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; } /// public async Task Finalize(Action? progressCallback, CancellationToken cancel) { if (_targetDisk == null) throw new InvalidOperationException("Provider not initialized."); var totalItems = _pendingWrites.Count; if (totalItems == 0) { Log.WriteInformationMessage(LOGTAG, "RestoreNoItems", "No items to restore."); return; } Log.WriteInformationMessage(LOGTAG, "RestoreStarting", $"Starting final restore of {totalItems} items to {_devicePath}"); var processedCount = 0; // Group items by type for ordered restoration var diskItems = _pendingWrites.Where(kv => kv.Value is DiskPendingWrite).ToList(); var partitionItems = _pendingWrites.Where(kv => kv.Value is PartitionPendingWrite).ToList(); // Restore disk-level items (partition table) if (!_skipPartitionTable && diskItems.Count > 0) { if (_geometryMetadata?.PartitionTable != null) { try { var partitionTableData = PartitionTableSynthesizer.SynthesizePartitionTable(_geometryMetadata); 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) { await PartitionTableSynthesizer.WriteSecondaryGPTAsync(_targetDisk, _geometryMetadata, partitionTableData, cancel).ConfigureAwait(false); Log.WriteInformationMessage(LOGTAG, "SecondaryGPTWritten", "Successfully wrote secondary GPT header and partition entries."); } } } 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."); } processedCount += diskItems.Count; progressCallback?.Invoke(processedCount / (double)totalItems); } // Restore partition-level items if (partitionItems.Count > 0) { // Currently a NOP operation. If a partition needs to restore // specific data during restore, it's here. processedCount += partitionItems.Count; progressCallback?.Invoke(processedCount / (double)totalItems); } // Cleanup foreach (var pendingWrite in _pendingWrites.Values) pendingWrite.Dispose(); _pendingWrites.Clear(); Log.WriteInformationMessage(LOGTAG, "RestoreComplete", "Restore operation completed."); } /// /// Normalizes the given path. /// /// The path to normalize. /// The normalized path. private string NormalizePath(string path) { // Remove any leading/trailing separators and normalize return path.TrimStart('/', '\\').TrimEnd('/', '\\'); } /// /// Disposes the restore provider. /// public void Dispose() { if (_disposed) return; _targetDisk?.Dispose(); foreach (var pendingWrite in _pendingWrites.Values) pendingWrite.Dispose(); _pendingWrites.Clear(); _disposed = true; } /// /// Reconstructs IRawDisk, IPartitionTable, IPartition, and IFilesystem objects /// from the geometry metadata. This is called when geometry.json is written during restore. /// /// Thrown if geometry metadata or target disk is not available. 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 { PartitionTableType.GPT => new ReconstructedPartitionTable(_targetDisk, _geometryMetadata, PartitionTableType.GPT), PartitionTableType.MBR => new ReconstructedPartitionTable(_targetDisk, _geometryMetadata, PartitionTableType.MBR), PartitionTableType.Unknown => new UnknownPartitionTable(_targetDisk), _ => null }; } // Reconstruct partitions from metadata if (_geometryMetadata.Partitions != null && partitionTable != null) { foreach (var partGeom in _geometryMetadata.Partitions) { 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 }; _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); } } } } /// /// Creates an IFilesystem instance from filesystem geometry metadata. /// /// The partition to create the filesystem for. /// The filesystem geometry metadata. /// An IFilesystem instance, or null if the filesystem type is not supported. 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) }; } /// /// A stream that captures the written data when disposed and invokes a callback. /// private class CaptureStream : Stream { private readonly MemoryStream _innerStream; private readonly Action> _onCaptured; private bool _disposed = false; public CaptureStream(MemoryStream innerStream, Action> onCaptured) { _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; var data = _innerStream.GetBuffer().AsMemory(0, (int)_innerStream.Length); _onCaptured(data); _innerStream.Dispose(); } _disposed = true; } base.Dispose(disposing); } } }