using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Proprietary.DiskImage.Filesystem;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest.DiskImage.UnitTests;
#nullable enable
public partial class DiskImageUnitTests : BasicSetupHelper
{
///
/// Tests that writing partial data (less than block size) is correctly flushed on dispose.
///
[Test]
public async Task Test_UnknownFilesystemStream_WritePartialData_FlushesOnDispose_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write partial data (less than block size)
var writeData = new byte[1024]; // 1KB, much less than 1MB block
new Random(42).NextBytes(writeData);
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Read back and verify - the written data should be preserved
byte[] readData;
using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
{
readData = new byte[writeData.Length];
var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None);
Assert.AreEqual(writeData.Length, bytesRead, "Should have read all written bytes.");
}
// Verify data matches
Assert.AreEqual(writeData, readData, "Partial data should be correctly flushed and readable.");
}
///
/// Tests writing at position 0, seeking to middle, writing more data, and verifying both regions.
///
[Test]
public async Task Test_UnknownFilesystemStream_WriteSeekWrite_BothRegionsPreserved_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write at position 0, seek to middle, write more data
var firstWriteData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
var secondWriteData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
var middlePosition = 1000;
using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None))
{
// Write at position 0
await writeStream.WriteAsync(firstWriteData.AsMemory(), CancellationToken.None);
// Seek to middle position
writeStream.Seek(middlePosition, SeekOrigin.Begin);
// Write more data
await writeStream.WriteAsync(secondWriteData.AsMemory(), CancellationToken.None);
}
// Read back and verify both regions
byte[] readData;
using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
{
readData = new byte[middlePosition + secondWriteData.Length];
var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None);
Assert.AreEqual(middlePosition + secondWriteData.Length, bytesRead, "Should have read all data.");
}
// Verify first region (position 0-4)
for (int i = 0; i < firstWriteData.Length; i++)
Assert.AreEqual(firstWriteData[i], readData[i], $"Byte at position {i} should match first write.");
// Verify middle region (position 1000-1003)
for (int i = 0; i < secondWriteData.Length; i++)
Assert.AreEqual(secondWriteData[i], readData[middlePosition + i], $"Byte at position {middlePosition + i} should match second write.");
}
///
/// Tests reading after seeking to a non-zero position.
///
[Test]
public async Task Test_UnknownFilesystemStream_ReadAfterSeek_ReturnsCorrectData_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write test data
var writeData = new byte[4096]; // 4KB of test data
for (int i = 0; i < writeData.Length; i++)
writeData[i] = (byte)(i & 0xFF);
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Read after seeking to non-zero position
using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
{
// Seek to position 100
readStream.Seek(100, SeekOrigin.Begin);
// Read remaining data
var readBuffer = new byte[writeData.Length - 100];
var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(writeData.Length - 100, bytesRead, "Should have read remaining bytes after seek.");
// Verify data matches expected values
for (int i = 0; i < bytesRead; i++)
{
Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {100 + i} should match.");
}
}
}
///
/// Tests that writing data that doesn't fill the entire stream preserves the written data
/// while the remaining bytes retain their previous values.
///
[Test]
public async Task Test_UnknownFilesystemStream_WritePartial_PreservesData_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
// Use a small block size for this test
var blockSize = 4096;
using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// First, initialize the entire block with zeros by writing zeros
var zeroData = new byte[blockSize];
using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await zeroStream.WriteAsync(zeroData.AsMemory(), CancellationToken.None);
// Now write partial data at the beginning
var writeData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 };
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Read the entire block and verify written data and remaining zeros
using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
var readBuffer = new byte[blockSize];
var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(blockSize, bytesRead, "Should have read the full block.");
// Verify written data
for (int i = 0; i < writeData.Length; i++)
Assert.AreEqual(writeData[i], readBuffer[i], $"Byte at position {i} should match written data.");
// Verify remaining bytes are still zero
for (int i = writeData.Length; i < blockSize; i++)
Assert.AreEqual(0, readBuffer[i], $"Byte at position {i} should be zero.");
}
///
/// Tests the lazy-load behavior: first read triggers disk read, subsequent reads use buffer.
///
[Test]
public async Task Test_UnknownFilesystemStream_LazyLoad_FirstReadTriggersDiskRead_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write test data first
var writeData = new byte[2048];
new Random(123).NextBytes(writeData);
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Now test lazy loading - first read should trigger disk read
using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
// First small read (triggers lazy load)
var firstReadBuffer = new byte[512];
var bytesRead1 = await readStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(512, bytesRead1, "First read should return 512 bytes.");
// Second read should use cached buffer (no disk read)
var secondReadBuffer = new byte[512];
var bytesRead2 = await readStream.ReadAsync(secondReadBuffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(512, bytesRead2, "Second read should return 512 bytes from buffer.");
// Verify both reads returned correct data
for (int i = 0; i < 512; i++)
{
Assert.AreEqual(writeData[i], firstReadBuffer[i], $"First read: byte at position {i} should match.");
Assert.AreEqual(writeData[i + 512], secondReadBuffer[i], $"Second read: byte at position {i + 512} should match.");
}
// Seek back and read again - should still use buffer
readStream.Seek(0, SeekOrigin.Begin);
var thirdReadBuffer = new byte[512];
var bytesRead3 = await readStream.ReadAsync(thirdReadBuffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(512, bytesRead3, "Third read after seek should return 512 bytes.");
for (int i = 0; i < 512; i++)
Assert.AreEqual(writeData[i], thirdReadBuffer[i], $"Third read: byte at position {i} should match after seek.");
}
///
/// Tests seeking with SeekOrigin.Current works correctly.
///
[Test]
public async Task Test_UnknownFilesystemStream_SeekCurrent_UpdatesPosition_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write test data
var writeData = new byte[4096];
for (int i = 0; i < writeData.Length; i++)
writeData[i] = (byte)(i & 0xFF);
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Test Seek with SeekOrigin.Current
using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
{
// Read first 100 bytes
var buffer1 = new byte[100];
var bytesRead1 = await readStream.ReadAsync(buffer1.AsMemory(), CancellationToken.None);
Assert.AreEqual(100, bytesRead1, "First read should return 100 bytes.");
Assert.AreEqual(100, readStream.Position, "Position should be 100 after first read.");
// Seek forward 100 bytes from current
var newPos = readStream.Seek(100, SeekOrigin.Current);
Assert.AreEqual(200, newPos, "Position should be 200 after Seek(Current, 100).");
Assert.AreEqual(200, readStream.Position, "Stream.Position should match returned position.");
// Read next 100 bytes
var buffer2 = new byte[100];
var bytesRead2 = await readStream.ReadAsync(buffer2.AsMemory(), CancellationToken.None);
Assert.AreEqual(100, bytesRead2, "Second read should return 100 bytes.");
// Verify data at position 200
for (int i = 0; i < 100; i++)
Assert.AreEqual((byte)((200 + i) & 0xFF), buffer2[i], $"Byte at position {200 + i} should match.");
}
}
///
/// Tests seeking with SeekOrigin.End works correctly.
///
[Test]
public async Task Test_UnknownFilesystemStream_SeekEnd_UpdatesPosition_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
var blockSize = 4096;
using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write test data
var writeData = new byte[blockSize];
for (int i = 0; i < writeData.Length; i++)
writeData[i] = (byte)(i & 0xFF);
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Test Seek with SeekOrigin.End
using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
// Seek to 100 bytes from end
var newPos = readStream.Seek(-100, SeekOrigin.End);
Assert.AreEqual(blockSize - 100, newPos, "Position should be blockSize - 100.");
Assert.AreEqual(blockSize - 100, readStream.Position, "Stream.Position should match.");
// Read remaining bytes
var buffer = new byte[100];
var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(100, bytesRead, "Should read 100 bytes.");
// Verify last 100 bytes
for (int i = 0; i < 100; i++)
Assert.AreEqual((byte)((blockSize - 100 + i) & 0xFF), buffer[i], $"Byte at position {blockSize - 100 + i} should match.");
}
///
/// Tests that reading past the end of the stream returns 0 bytes.
///
[Test]
public async Task Test_UnknownFilesystemStream_ReadPastEnd_ReturnsZero_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
var blockSize = 4096;
using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// Write small amount of test data
var writeData = new byte[100];
new Random(42).NextBytes(writeData);
using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None);
// Test reading past end
using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None);
// Seek to near end
readStream.Seek(blockSize - 10, SeekOrigin.Begin);
// Try to read 100 bytes when only 10 remain
var buffer = new byte[100];
var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(10, bytesRead, "Should only read remaining 10 bytes.");
// Try to read again - should return 0
bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream.");
}
///
/// Tests that unaligned writes within the stream work correctly.
///
[Test]
public async Task Test_UnknownFilesystemStream_UnalignedWrite_DataMatches_Async()
{
var sectorSize = _writableRawDisk.SectorSize;
var partitionSize = 5 * MiB;
var partitionOffset = sectorSize * 2048;
var partition = new TestPartition
{
StartOffset = partitionOffset,
Size = partitionSize,
RawDisk = _writableRawDisk
};
var blockSize = 8192;
using var fs = new UnknownFilesystem(partition, blockSize: blockSize);
// Get first file
IFile? firstFile = null;
await foreach (var file in fs.ListFilesAsync(CancellationToken.None))
{
firstFile = file;
break;
}
Assert.IsNotNull(firstFile, "Should have at least one file.");
// First, zero-fill the block so gap bytes are deterministic
using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None))
await zeroStream.WriteAsync(new byte[blockSize].AsMemory(), CancellationToken.None);
// Write at unaligned positions using read-write stream (pre-loads zeroed data)
using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None))
{
// Write 3 bytes at position 0
await writeStream.WriteAsync(new byte[] { 0xAA, 0xBB, 0xCC }.AsMemory(), CancellationToken.None);
// Seek to unaligned position 5
writeStream.Seek(5, SeekOrigin.Begin);
await writeStream.WriteAsync(new byte[] { 0xDD, 0xEE }.AsMemory(), CancellationToken.None);
// Seek to another unaligned position 10
writeStream.Seek(10, SeekOrigin.Begin);
await writeStream.WriteAsync(new byte[] { 0x11, 0x22, 0x33, 0x44 }.AsMemory(), CancellationToken.None);
}
// Read back and verify
using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None))
{
var readBuffer = new byte[14];
var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None);
Assert.AreEqual(14, bytesRead, "Should read 14 bytes.");
// Verify written data
Assert.AreEqual(0xAA, readBuffer[0], "Byte 0 should be 0xAA");
Assert.AreEqual(0xBB, readBuffer[1], "Byte 1 should be 0xBB");
Assert.AreEqual(0xCC, readBuffer[2], "Byte 2 should be 0xCC");
// Bytes 3-4 should be zero
Assert.AreEqual(0, readBuffer[3], "Byte 3 should be 0");
Assert.AreEqual(0, readBuffer[4], "Byte 4 should be 0");
Assert.AreEqual(0xDD, readBuffer[5], "Byte 5 should be 0xDD");
Assert.AreEqual(0xEE, readBuffer[6], "Byte 6 should be 0xEE");
// Bytes 7-9 should be zero
Assert.AreEqual(0, readBuffer[7], "Byte 7 should be 0");
Assert.AreEqual(0, readBuffer[8], "Byte 8 should be 0");
Assert.AreEqual(0, readBuffer[9], "Byte 9 should be 0");
Assert.AreEqual(0x11, readBuffer[10], "Byte 10 should be 0x11");
Assert.AreEqual(0x22, readBuffer[11], "Byte 11 should be 0x22");
Assert.AreEqual(0x33, readBuffer[12], "Byte 12 should be 0x33");
Assert.AreEqual(0x44, readBuffer[13], "Byte 13 should be 0x44");
}
}
}