diff --git a/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs b/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs new file mode 100644 index 000000000..de479b5ff --- /dev/null +++ b/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs @@ -0,0 +1,938 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Duplicati.Proprietary.DiskImage.Disk; +using Duplicati.Proprietary.DiskImage.General; +using Duplicati.Proprietary.DiskImage.Partition; + +namespace Duplicati.Proprietary.DiskImage.Filesystem.Ntfs; + +/// +/// Metadata for NTFS filesystems, storing cluster and block size information. +/// +public sealed record NtfsFilesystemMetadata +{ + /// + /// Gets the block size used for reading/writing data. + /// + public int BlockSize { get; init; } + + /// + /// Gets the size of a cluster in bytes. + /// + public int ClusterSize { get; init; } + + /// + /// Gets the total number of clusters in the volume. + /// + public long TotalClusters { get; init; } + + /// + /// Gets the number of allocated (in-use) clusters. + /// + public long AllocatedClusters { get; init; } + + /// + /// Gets the number of free clusters. + /// + public long FreeClusters { get; init; } + + /// + /// Gets the size of an MFT record in bytes. + /// + public int MftRecordSize { get; init; } + + /// + /// Gets a value indicating whether the filesystem has a journal ($LogFile). + /// Always true for NTFS. + /// + public bool HasJournal => true; +} + +/// +/// Represents a file (block) in an NTFS filesystem. +/// +public class NtfsFile : IFile +{ + /// + public string? Path { get; init; } + + /// + public long? Address { get; init; } + + /// + public long Size { get; init; } + + /// + public bool IsDirectory => false; + + /// + /// Gets the last modification timestamp of the block. + /// This is the maximum timestamp of any file whose clusters overlap this block. + /// + public DateTime? LastModified { get; init; } + + /// + /// Gets a value indicating whether the block contains any allocated clusters. + /// + public bool IsAllocated { get; init; } +} + +/// +/// Represents an NTFS filesystem. +/// Provides filesystem-aware block-level access with cluster allocation tracking. +/// +internal class NtfsFilesystem : IFilesystem +{ + /// + /// Cache of zero buffers keyed by block size. + /// + private static readonly ConcurrentDictionary s_zeroBuffers = new(); + + /// + public FileSystemType Type => FileSystemType.NTFS; + + /// + public IPartition Partition { get; } + + /// + /// Gets the block size used for reading/writing data. + /// + private readonly int m_blockSize; + + /// + /// The parsed NTFS boot sector. + /// + private readonly NtfsBootSector m_bootSector; + + /// + /// The bitmap reader for cluster allocation. + /// + private readonly NtfsBitmap m_bitmap; + + /// + /// The MFT walker that built the cluster-to-timestamp map. + /// + private readonly NtfsMftWalker m_mftWalker; + + /// + /// Maps block index to block metadata (LastModified, IsAllocated). + /// + private readonly BlockMetadata[] m_blockMetadata; + + /// + /// The total number of blocks in the partition. + /// + private readonly long m_blockCount; + + /// + /// Flag to indicate whether the object has been disposed. + /// + private bool m_disposed; + + /// + /// Metadata for a single block. + /// + private readonly struct BlockMetadata + { + /// + /// The last modification timestamp for this block. + /// + public DateTime LastModified { get; init; } + + /// + /// Whether this block contains any allocated clusters. + /// + public bool IsAllocated { get; init; } + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent partition. + /// The block size for reading/writing (default is 1MB). + /// Thrown if partition is null. + /// Thrown if block size is invalid. + internal NtfsFilesystem(IPartition partition, int blockSize = 1024 * 1024) + { + ArgumentNullException.ThrowIfNull(partition); + + Partition = partition; + + var sectorSize = partition.PartitionTable.RawDisk?.SectorSize ?? 512; + if (blockSize <= 0 || blockSize % sectorSize != 0) + throw new ArgumentException($"Block size must be positive and a multiple of the sector size ({sectorSize} bytes).", nameof(blockSize)); + + m_blockSize = blockSize; + + // Read and parse the boot sector + var bootSectorData = new byte[512]; + using (var stream = partition.OpenReadAsync(CancellationToken.None).Result) + { + stream.ReadExactly(bootSectorData); + } + m_bootSector = new NtfsBootSector(bootSectorData); + + // Validate block size is a multiple of cluster size + if (m_blockSize % m_bootSector.ClusterSize != 0) + throw new ArgumentException($"Block size ({m_blockSize}) must be a multiple of the cluster size ({m_bootSector.ClusterSize}).", nameof(blockSize)); + + // Read the bitmap + m_bitmap = new NtfsBitmap(partition, m_bootSector, CancellationToken.None); + + // Walk the MFT to build cluster-to-timestamp map + m_mftWalker = new NtfsMftWalker(partition, m_bootSector, m_bitmap); + m_mftWalker.WalkAsync(CancellationToken.None).Wait(); + + // Pre-compute block metadata + m_blockCount = (partition.Size + m_blockSize - 1) / m_blockSize; + m_blockMetadata = BuildBlockMetadata(); + } + + /// + /// Builds the metadata array for all blocks. + /// + /// An array of BlockMetadata for each block. + private BlockMetadata[] BuildBlockMetadata() + { + var metadata = new BlockMetadata[m_blockCount]; + var clusterToTimestampMap = m_mftWalker.ClusterToTimestampMap; + var clusterSize = m_bootSector.ClusterSize; + var totalClusters = m_bootSector.TotalClusters; + var now = DateTime.UtcNow; + + // The boot sector area (first cluster) is always allocated with current timestamp + // In NTFS, data starts at cluster 0 which is immediately after the boot sector + var bootSectorEnd = m_bootSector.BytesPerSector * m_bootSector.SectorsPerCluster; // End of first cluster + + for (long blockIndex = 0; blockIndex < m_blockCount; blockIndex++) + { + long blockStart = blockIndex * m_blockSize; + long blockEnd = Math.Min(blockStart + m_blockSize, Partition.Size); + + // Check if block is in the boot sector area (first cluster) + if (blockStart < bootSectorEnd) + { + // Boot sector area is always allocated + metadata[blockIndex] = new BlockMetadata + { + LastModified = now, + IsAllocated = true + }; + continue; + } + + // Check if block is beyond the volume + if (blockStart >= m_bootSector.TotalSize) + { + // Beyond the volume - treat as unallocated with epoch timestamp + metadata[blockIndex] = new BlockMetadata + { + LastModified = DateTime.UnixEpoch, + IsAllocated = false + }; + continue; + } + + // Block is in the volume - check which clusters fall within it + DateTime maxTimestamp = DateTime.UnixEpoch; + bool hasAllocatedClusters = false; + + // Calculate which clusters are in this block + long firstClusterInBlock = blockStart / clusterSize; + long lastClusterInBlock = (blockEnd - 1) / clusterSize; + + // Clamp to valid cluster range + firstClusterInBlock = Math.Max(0, firstClusterInBlock); + lastClusterInBlock = Math.Min(lastClusterInBlock, totalClusters - 1); + + for (long cluster = firstClusterInBlock; cluster <= lastClusterInBlock; cluster++) + { + if (m_bitmap.IsClusterAllocated(cluster)) + { + hasAllocatedClusters = true; + + // Get the timestamp for this cluster + if (clusterToTimestampMap.TryGetValue(cluster, out var timestamp)) + { + if (timestamp > maxTimestamp) + { + maxTimestamp = timestamp; + } + } + else + { + // Allocated cluster without a timestamp - use current time + maxTimestamp = now; + } + } + } + + metadata[blockIndex] = new BlockMetadata + { + LastModified = maxTimestamp, + IsAllocated = hasAllocatedClusters + }; + } + + return metadata; + } + + /// + /// Tries to detect whether a given partition has an NTFS file system. + /// + /// The raw disk to check for the filesystem. + /// The offset on the disk where the partition starts. + /// The size of the partition. + /// Cancellation token. + /// A task that resolves to true if a valid NTFS filesystem is detected, otherwise false. + public static async Task DetectAsync(IRawDisk disk, long offset, int size, CancellationToken cancellationToken) + { + try + { + var bootSectorData = new byte[512]; + using var stream = await disk.ReadBytesAsync(offset, size, cancellationToken); + var bytesRead = await stream.ReadAsync(bootSectorData, cancellationToken); + if (bytesRead < 512) + return false; + + // Try NTFS: the constructor validates the boot sector signature and + // the "NTFS " OEM ID at offset 0x03. + _ = new NtfsBootSector(bootSectorData); + return true; + } + catch + { + // Unable to read the partition or invalid boot sector + } + + return false; + } + + /// + public void Dispose() + { + if (m_disposed) + return; + + m_disposed = true; + } + + /// + public Task GetFilesystemMetadataAsync(CancellationToken cancellationToken) + { + var metadata = new NtfsFilesystemMetadata + { + BlockSize = m_blockSize, + ClusterSize = m_bootSector.ClusterSize, + TotalClusters = m_bitmap.TotalClusters, + AllocatedClusters = m_bitmap.AllocatedClusters, + FreeClusters = m_bitmap.FreeClusters, + MftRecordSize = m_bootSector.MftRecordSize + }; + + return Task.FromResult(metadata); + } + + /// + public async IAsyncEnumerable ListFilesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + for (long i = 0; i < m_blockCount; i++) + { + long address = i * m_blockSize; + long size = Math.Min(m_blockSize, Partition.Size - i * m_blockSize); + var blockMeta = m_blockMetadata[i]; + + if (size > 0) + { + yield return new NtfsFile() + { + Path = $"block_{i:X016}", + Address = address, + Size = size, + LastModified = blockMeta.LastModified, + IsAllocated = blockMeta.IsAllocated + }; + } + } + } + + /// + public async IAsyncEnumerable ListFilesAsync(IFile directory, [EnumeratorCancellation] CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(directory); + + if (directory is not NtfsFile) + throw new ArgumentException("The specified directory does not belong to this filesystem.", nameof(directory)); + if (!directory.IsDirectory) + throw new ArgumentException("The specified file is not a directory.", nameof(directory)); + + yield break; + } + + /// + public async Task OpenReadStreamAsync(IFile file, CancellationToken cancellationToken) + { + if (file is not NtfsFile ntfsFile) + throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file)); + + if (file.IsDirectory) + throw new ArgumentException("The specified file is a directory.", nameof(file)); + + long address = ntfsFile.Address ?? throw new ArgumentException("File address is required.", nameof(file)); + long size = ntfsFile.Size; + + BoundsCheck(address, size); + + if (ntfsFile.IsAllocated) + return new NtfsStream(Partition.PartitionTable.RawDisk!, Partition.StartOffset + address, size, readEnabled: true, writeEnabled: false); + else + return new NtfsZeroStream((int)size); + } + + /// + public async Task OpenReadStreamAsync(string path, CancellationToken cancellationToken) + { + long address = ParsePathToAddress(path); + long size = Math.Min((long)m_blockSize, Partition.Size - address); + + return await OpenReadStreamAsync(new NtfsFile { Address = address, Size = size, IsAllocated = true }, cancellationToken); + } + + /// + public async Task OpenWriteStreamAsync(IFile file, CancellationToken cancellationToken) + { + if (file is not NtfsFile ntfsFile) + throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file)); + + if (file.IsDirectory) + throw new ArgumentException("The specified file is a directory.", nameof(file)); + + long address = ntfsFile.Address ?? throw new ArgumentException("File address is required.", nameof(file)); + long requestedSize = ntfsFile.Size; + + BoundsCheck(address, requestedSize); + + // Calculate actual available size based on target partition + // This ensures we fail fast if the target is too small + long actualSize = Math.Min(requestedSize, Partition.Size - address); + if (actualSize < requestedSize) + throw new IOException($"Target partition is too small to write block at address 0x{address:X}. " + + $"Requested size: {requestedSize} bytes, Available: {actualSize} bytes. " + + $"The target disk may be smaller than the source disk."); + + // For write operations, always use the full stream + return new NtfsStream(Partition.PartitionTable.RawDisk!, Partition.StartOffset + address, actualSize, readEnabled: false, writeEnabled: true); + } + + /// + public async Task OpenWriteStreamAsync(string path, CancellationToken cancellationToken) + { + long address = ParsePathToAddress(path); + long size = Math.Min((long)m_blockSize, Partition.Size - address); + + return await OpenWriteStreamAsync(new NtfsFile { Address = address, Size = size, IsAllocated = true }, cancellationToken); + } + + /// + public async Task OpenReadWriteStreamAsync(IFile file, CancellationToken cancellationToken) + { + if (file is not NtfsFile ntfsFile) + throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file)); + + if (file.IsDirectory) + throw new ArgumentException("The specified file is a directory.", nameof(file)); + + long address = ntfsFile.Address ?? throw new ArgumentException("File address is required.", nameof(file)); + long requestedSize = ntfsFile.Size; + + BoundsCheck(address, requestedSize); + + // Calculate actual available size based on target partition + // This ensures we fail fast if the target is too small + long actualSize = Math.Min(requestedSize, Partition.Size - address); + if (actualSize < requestedSize) + throw new IOException($"Target partition is too small to write block at address 0x{address:X}. " + + $"Requested size: {requestedSize} bytes, Available: {actualSize} bytes. " + + $"The target disk may be smaller than the source disk."); + + return new NtfsStream(Partition.PartitionTable.RawDisk!, Partition.StartOffset + address, actualSize, readEnabled: true, writeEnabled: true); + } + + /// + public async Task OpenReadWriteStreamAsync(string path, CancellationToken cancellationToken) + { + long address = ParsePathToAddress(path); + long size = Math.Min((long)m_blockSize, Partition.Size - address); + + return await OpenReadWriteStreamAsync(new NtfsFile { Address = address, Size = size, IsAllocated = true }, cancellationToken); + } + + /// + public Task GetFileLengthAsync(IFile file, CancellationToken cancellationToken) + { + if (file is not NtfsFile ntfsFile) + throw new ArgumentException("The specified file does not belong to this filesystem.", nameof(file)); + + if (file.IsDirectory) + throw new ArgumentException("The specified file is a directory.", nameof(file)); + + return Task.FromResult(ntfsFile.Size); + } + + /// + public Task GetFileLengthAsync(string path, CancellationToken cancellationToken) + { + long address = ParsePathToAddress(path); + long size = Math.Min((long)m_blockSize, Partition.Size - address); + + return Task.FromResult(size); + } + + /// + /// Parses a file path to extract the corresponding block address. + /// + /// The file path to parse. + /// The block address corresponding to the file path. + /// Thrown if the path format is invalid. + private static long ParsePathToAddress(string path) + { + // Path format: root/part_{PartitionTableType}_{PartitionNumber}/fs_{FileSystemType}/{Address} + // We need to extract the part after the last slash + var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 4) + throw new ArgumentException("Invalid path format.", nameof(path)); + + return Convert.ToInt64(parts[^1], 16); + } + + /// + /// Checks if the specified range is valid for the partition. + /// + /// The starting address of the range. + /// The size of the range. + /// Thrown if the start or size is negative or exceeds the partition size. + /// Thrown if the start or size is not a multiple of the sector size. + public void BoundsCheck(long start, long size) + { + if (start < 0 || size < 0) + throw new ArgumentOutOfRangeException("Start and size must be non-negative."); + + if (start + size > Partition.Size) + throw new ArgumentOutOfRangeException("The specified range exceeds the partition size."); + + var sectorSize = Partition.PartitionTable.RawDisk?.SectorSize ?? 512; + + if (start % sectorSize != 0 || size % sectorSize != 0) + throw new ArgumentException($"Start and size must be multiples of the sector size ({sectorSize} bytes)."); + } + + /// + /// A stream that reads data from disk and buffers it in memory for writing back on dispose. + /// Similar to Fat32Stream but specific to NTFS allocated blocks. + /// + private class NtfsStream : Stream + { + /// + /// The raw disk to read from. + /// + private readonly IRawDisk _disk; + + /// + /// The starting address on the disk for this stream. + /// + private readonly long _address; + + /// + /// The size of the stream in bytes. + /// + private readonly long _size; + + /// + /// The buffer used to store data before writing to disk. + /// + private readonly byte[] _buffer; + + /// + /// Indicates whether the buffer was rented from the ArrayPool. + /// + private readonly bool _bufferRented; + + /// + /// The current position within the buffer. + /// + private long _position; + + /// + /// Indicates whether the data in the buffer is valid (has been read from disk). + /// + private bool _validData; + + /// + /// Indicates whether the stream has been disposed. + /// + private bool _disposed; + + /// + /// Indicates whether the stream supports reading. + /// + private readonly bool _readEnabled; + + /// + /// Indicates whether the stream supports writing. + /// + private readonly bool _writeEnabled; + + /// + /// Indicates whether the buffer has been modified (dirty). + /// + private bool _dirty; + + /// + /// Initializes a new instance of the class. + /// + /// The raw disk to read from/write to. + /// The starting address on the disk for this stream. + /// The size of the stream in bytes. + /// Whether reading is enabled. + /// Whether writing is enabled. + public NtfsStream(IRawDisk disk, long address, long size, bool readEnabled, bool writeEnabled) + { + _disk = disk; + _address = address; + _size = size; + _readEnabled = readEnabled; + _writeEnabled = writeEnabled; + // Rent buffer from ArrayPool to avoid LOH allocations for large buffers (typically 1 MB) + _buffer = ArrayPool.Shared.Rent((int)size); + _bufferRented = true; + _position = 0; + _validData = false; + _disposed = false; + _dirty = false; + } + + public override bool CanRead => _readEnabled; + public override bool CanWrite => _writeEnabled; + public override bool CanSeek => true; + public override long Length => _size; + public override long Position + { + get => _position; + set + { + if (value < 0 || value > _size) + throw new ArgumentOutOfRangeException(nameof(value)); + _position = value; + } + } + + public override void Flush() + { + // No-op - data is written on dispose + } + + public override long Seek(long offset, SeekOrigin origin) + { + long newPosition = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _size + offset, + _ => throw new ArgumentException("Invalid seek origin", nameof(origin)) + }; + + if (newPosition < 0 || newPosition > _size) + throw new ArgumentOutOfRangeException(nameof(offset)); + + _position = newPosition; + return _position; + } + + public override void SetLength(long value) + { + throw new NotSupportedException("Cannot change the length of this stream."); + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (!_readEnabled) + throw new NotSupportedException("Read is not supported on this stream."); + + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (buffer.Length - offset < count) + throw new ArgumentException("Invalid offset and count for buffer length"); + + if (!_validData) + { + // Load the data from disk into the buffer on the first read + _disk.ReadBytesAsync(_address, _buffer.AsMemory(0, (int)_size), CancellationToken.None).GetAwaiter().GetResult(); + _validData = true; + } + + int bytesToRead = (int)Math.Min(count, _size - _position); + if (bytesToRead <= 0) + return 0; + + Buffer.BlockCopy(_buffer, (int)_position, buffer, offset, bytesToRead); + _position += bytesToRead; + return bytesToRead; + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (!_writeEnabled) + throw new NotSupportedException("Write is not supported on this stream."); + + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (buffer.Length - offset < count) + throw new ArgumentException("Invalid offset and count for buffer length"); + + if (_position >= _size) + throw new IOException($"Cannot write at position {_position}: stream has ended (size: {_size}). The target partition may be smaller than the source."); + + Buffer.BlockCopy(buffer, offset, _buffer, (int)_position, count); + _position += count; + _validData = true; + _dirty = true; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (!_readEnabled) + throw new NotSupportedException("Read is not supported on this stream."); + + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (buffer.Length - offset < count) + throw new ArgumentException("Invalid offset and count for buffer length"); + + if (!_validData) + { + // Load the data from disk into the buffer on the first read + await _disk.ReadBytesAsync(_address, _buffer.AsMemory(0, (int)_size), cancellationToken); + _validData = true; + } + + int bytesToRead = (int)Math.Min(count, _size - _position); + if (bytesToRead <= 0) + return 0; + + Buffer.BlockCopy(_buffer, (int)_position, buffer, offset, bytesToRead); + _position += bytesToRead; + return bytesToRead; + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (!_writeEnabled) + throw new NotSupportedException("Write is not supported on this stream."); + + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (buffer.Length - offset < count) + throw new ArgumentException("Invalid offset and count for buffer length"); + + if (_position >= _size) + throw new IOException($"Cannot write at position {_position}: stream has ended (size: {_size}). The target partition may be smaller than the source."); + + Buffer.BlockCopy(buffer, offset, _buffer, (int)_position, count); + _position += count; + _validData = true; + _dirty = true; + + await Task.CompletedTask; + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + if (_writeEnabled && _dirty) + { + // Write all buffered data to disk + _disk.WriteBytesAsync(_address, _buffer.AsMemory(0, (int)_size), CancellationToken.None).GetAwaiter().GetResult(); + } + // Return the rented buffer to the pool + if (_bufferRented) + { + ArrayPool.Shared.Return(_buffer); + } + } + _disposed = true; + } + base.Dispose(disposing); + } + + /// + /// Asynchronously releases the unmanaged resources used by the . + /// + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + if (_writeEnabled && _dirty) + { + // Write all buffered data to disk + await _disk.WriteBytesAsync(_address, _buffer.AsMemory(0, (int)_size), CancellationToken.None); + } + // Return the rented buffer to the pool + if (_bufferRented) + { + ArrayPool.Shared.Return(_buffer); + } + _disposed = true; + } + await base.DisposeAsync(); + } + } + + /// + /// A stream that returns zeros without reading from disk. + /// Used for unallocated blocks to optimize backup performance. + /// + internal class NtfsZeroStream : Stream + { + /// + /// The size of the stream in bytes. + /// + private readonly long _size; + + /// + /// The shared zero buffer for this block size. + /// + private readonly byte[] _zeroBuffer; + + /// + /// The current position within the stream. + /// + private long _position; + + /// + /// Indicates whether the stream has been disposed. + /// + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The size of the stream in bytes. + public NtfsZeroStream(int size) + { + if (size <= 0) + throw new ArgumentException("Size must be positive.", nameof(size)); + + _size = size; + _position = 0; + _disposed = false; + + // Get or create the shared zero buffer for this size + _zeroBuffer = s_zeroBuffers.GetOrAdd(size, s => new byte[s]); + } + + public override bool CanRead => true; + public override bool CanWrite => false; + public override bool CanSeek => true; + public override long Length => _size; + public override long Position + { + get => _position; + set + { + if (value < 0 || value > _size) + throw new ArgumentOutOfRangeException(nameof(value)); + _position = value; + } + } + + public override void Flush() + { + // No-op + } + + public override long Seek(long offset, SeekOrigin origin) + { + long newPosition = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _size + offset, + _ => throw new ArgumentException("Invalid seek origin", nameof(origin)) + }; + + if (newPosition < 0 || newPosition > _size) + throw new ArgumentOutOfRangeException(nameof(offset)); + + _position = newPosition; + return _position; + } + + public override void SetLength(long value) + { + throw new NotSupportedException("Cannot change the length of this stream."); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (buffer.Length - offset < count) + throw new ArgumentException("Invalid offset and count for buffer length"); + + int bytesToRead = (int)Math.Min(count, _size - _position); + if (bytesToRead <= 0) + return 0; + + // Copy zeros from the shared buffer + Buffer.BlockCopy(_zeroBuffer, 0, buffer, offset, bytesToRead); + _position += bytesToRead; + return bytesToRead; + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("Write is not supported on this stream."); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return Task.FromResult(Read(buffer, offset, count)); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + throw new NotSupportedException("Write is not supported on this stream."); + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + // No-op - the shared buffer is never returned + _disposed = true; + base.Dispose(disposing); + } + } +}