Merge pull request #6868 from carljohnsen/feature/full-disk-ntfs
Feature/Full disk NTFS support
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -109,4 +109,12 @@ public interface IDiskImageHelper : IDisposable
|
||||
/// <param name="readOnly">Indicates whether the disk should be re-attached as read-only.</param>
|
||||
/// <returns>The identifier of the re-attached disk.</returns>
|
||||
string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific file system type is supported on the current platform. This can be used to conditionally run tests that require certain file systems, or to skip tests if the required file system is not available.
|
||||
/// It also returns a message indicating the reason if the file system type is not supported.
|
||||
/// </summary>
|
||||
/// <param name="fsType">The file system type to check.</param>
|
||||
/// <returns><c>true</c> if the file system type is supported; otherwise, <c>false</c>.</returns>
|
||||
(bool, string) IsFileSystemTypeSupported(FileSystemType fsType);
|
||||
}
|
||||
|
||||
@@ -526,6 +526,29 @@ internal class LinuxDiskImageHelper : IDiskImageHelper
|
||||
return newLoopDevice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set of file system types that are supported on Linux.
|
||||
/// </summary>
|
||||
private readonly HashSet<FileSystemType> _supportedFileSystems = [
|
||||
FileSystemType.Btrfs,
|
||||
FileSystemType.Ext2,
|
||||
FileSystemType.Ext3,
|
||||
FileSystemType.Ext4,
|
||||
FileSystemType.ExFAT,
|
||||
FileSystemType.FAT32,
|
||||
FileSystemType.XFS,
|
||||
FileSystemType.Unknown
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public (bool, string) IsFileSystemTypeSupported(FileSystemType fsType)
|
||||
{
|
||||
if (_supportedFileSystems.Contains(fsType))
|
||||
return (true, $"{fsType} is supported on Linux");
|
||||
|
||||
return (false, $"{fsType} is not supported on Linux");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -640,6 +640,26 @@ internal class MacOSDiskImageHelper : IDiskImageHelper
|
||||
.First().First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set of file system types that are supported on macOS.
|
||||
/// </summary>
|
||||
private readonly HashSet<FileSystemType> _supportedFileSystems = [
|
||||
FileSystemType.APFS,
|
||||
FileSystemType.ExFAT,
|
||||
FileSystemType.FAT32,
|
||||
FileSystemType.HFSPlus,
|
||||
FileSystemType.Unknown
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public (bool, string) IsFileSystemTypeSupported(FileSystemType fsType)
|
||||
{
|
||||
if (_supportedFileSystems.Contains(fsType))
|
||||
return (true, $"{fsType} is supported on macOS");
|
||||
|
||||
return (false, $"{fsType} is not supported on macOS");
|
||||
}
|
||||
|
||||
public static string RunProcess(string fileName, string arguments, int retryCount = 0)
|
||||
{
|
||||
for (int i = 0; i <= retryCount; i++)
|
||||
|
||||
@@ -846,6 +846,25 @@ internal class WindowsDiskImageHelper : IDiskImageHelper
|
||||
return $@"\\.\PhysicalDrive{newDiskNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the set of supported filesystem types on Windows.
|
||||
/// </summary>
|
||||
private readonly HashSet<FileSystemType> _supportedFileSystems = [
|
||||
FileSystemType.ExFAT,
|
||||
FileSystemType.FAT32,
|
||||
FileSystemType.NTFS,
|
||||
FileSystemType.Unknown
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public (bool, string) IsFileSystemTypeSupported(FileSystemType fsType)
|
||||
{
|
||||
if (_supportedFileSystems.Contains(fsType))
|
||||
return (true, $"{fsType} is supported on Windows");
|
||||
|
||||
return (false, $"{fsType} is not supported on Windows");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.UnitTest.DiskImage.Helpers;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
|
||||
|
||||
public class FilesystemAwareTests : DiskImageTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests that incremental backup with filesystem awareness skips unchanged blocks.
|
||||
/// Creates a disk with the specified filesystem, performs initial backup, modifies one file,
|
||||
/// performs second backup, and verifies only changed blocks were processed.
|
||||
/// </summary>
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.NTFS)]
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
|
||||
[Category("DiskImage")]
|
||||
[Category("DiskImageFileSystem")]
|
||||
public async Task Test_FileSystem_IncrementalBackup_UnchangedBlocks_Skipped_Async(int size, PartitionTableType tableType, FileSystemType fsType)
|
||||
{
|
||||
var stopwatch = new Stopwatch();
|
||||
stopwatch.Start();
|
||||
|
||||
var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
|
||||
if (!isSupported)
|
||||
Assert.Ignore(reason);
|
||||
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Test: {fsType} Incremental Backup - Unchanged Blocks Skipped ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// When testing Unknown filesystem, format source as FAT32 to allow test data generation,
|
||||
// but treat it as unknown during backup
|
||||
var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
|
||||
var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
|
||||
|
||||
// Create source disk with specified partition
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath} ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Generate initial test data
|
||||
await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 10, 3, 1, 1024);
|
||||
_diskHelper.FlushDisk(sourceDrivePath);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Initial test data generated ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// First backup
|
||||
var firstBackupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
|
||||
|
||||
// When treating filesystem as unknown, the option may not be recognized by the Controller
|
||||
// so we only check for errors, not warnings
|
||||
if (treatFilesystemAsUnknown)
|
||||
{
|
||||
Assert.That(firstBackupResults.Errors.Count(), Is.EqualTo(0),
|
||||
"Backup should have no errors");
|
||||
}
|
||||
else
|
||||
{
|
||||
TestUtils.AssertResults(firstBackupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
}
|
||||
|
||||
var firstBackupSize = firstBackupResults.SizeOfOpenedFiles;
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"First backup completed: {firstBackupResults.OpenedFiles} files opened, {firstBackupSize} bytes ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Verify first backup processed the expected amount of data
|
||||
// With FAT32 awareness, unallocated blocks are returned as zeros (no disk read)
|
||||
Assert.That(firstBackupResults.SizeOfOpenedFiles, Is.GreaterThan(0),
|
||||
"First backup should have opened and processed files");
|
||||
|
||||
// Create restore disk for initial restore verification
|
||||
var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
|
||||
_diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
|
||||
_diskHelper.Unmount(restoreDrivePath);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Restore disk created at: {_restoreImagePath} ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
var restoreResults = await RunRestoreAsync(restoreDrivePath);
|
||||
TestUtils.AssertResults(restoreResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Initial restore completed successfully ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Reattach and verify initial restore
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
|
||||
restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
|
||||
|
||||
var fsTypes = new[] { sourceFsType };
|
||||
var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
|
||||
CompareDirectories(sourceMounted.First(), restoreMounted.First());
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Initial restore verified successfully ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Unmount both disks to prepare for modification
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
_diskHelper.Unmount(restoreDrivePath);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Both disks unmounted ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Remount source drive in write-enabled mode
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: false);
|
||||
sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: false, fileSystemTypes: fsTypes);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk remounted in write mode ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Modify a few files on the source - create new files and modify existing ones
|
||||
var sourcePartitionPath = sourceMounted.First();
|
||||
var modifiedFiles = new List<string>();
|
||||
|
||||
// Create a new directory with some new files
|
||||
var newDirPath = Path.Combine(sourcePartitionPath, "incremental_new_files");
|
||||
Directory.CreateDirectory(newDirPath);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var newFilePath = Path.Combine(newDirPath, $"new_file_{i}.txt");
|
||||
var content = $"This is new content for incremental backup test - file {i} - {Guid.NewGuid()}";
|
||||
await File.WriteAllTextAsync(newFilePath, content);
|
||||
// Set a recent modification time to ensure the backup detects the change
|
||||
File.SetLastWriteTimeUtc(newFilePath, DateTime.UtcNow);
|
||||
modifiedFiles.Add(newFilePath);
|
||||
}
|
||||
// Also update directory timestamp
|
||||
Directory.SetLastWriteTimeUtc(newDirPath, DateTime.UtcNow);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Created {modifiedFiles.Count} new files in {newDirPath} ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Modify an existing file if one exists
|
||||
var existingFiles = GetNonSystemFiles(sourcePartitionPath)
|
||||
.Where(f => f.EndsWith(".txt") && !f.Contains("incremental_new_files"))
|
||||
.Select(f => Path.Combine(sourcePartitionPath, f))
|
||||
.ToArray();
|
||||
if (existingFiles.Length > 0)
|
||||
{
|
||||
var fileToModify = existingFiles[0];
|
||||
var originalContent = await File.ReadAllTextAsync(fileToModify);
|
||||
var modifiedContent = originalContent + $"\n\nModified for incremental backup test - {Guid.NewGuid()}";
|
||||
await File.WriteAllTextAsync(fileToModify, modifiedContent);
|
||||
// Update modification time to ensure backup detects the change
|
||||
File.SetLastWriteTimeUtc(fileToModify, DateTime.UtcNow.AddSeconds(1));
|
||||
modifiedFiles.Add(fileToModify);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Modified existing file: {fileToModify} ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
}
|
||||
|
||||
// Flush and unmount source after modifications
|
||||
_diskHelper.FlushDisk(sourceDrivePath);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Modifications complete, source disk unmounted ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Re-attach the source disk for backup (disk needs to be attached for backup to access it)
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk re-attached for second backup at: {sourceDrivePath} ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Second backup (incremental)
|
||||
var secondBackupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
|
||||
|
||||
// When treating filesystem as unknown, the option may not be recognized by the Controller
|
||||
// so we only check for errors, not warnings
|
||||
if (treatFilesystemAsUnknown)
|
||||
{
|
||||
Assert.That(secondBackupResults.Errors.Count(), Is.EqualTo(0),
|
||||
"Backup should have no errors");
|
||||
}
|
||||
else
|
||||
{
|
||||
TestUtils.AssertResults(secondBackupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
}
|
||||
|
||||
var secondBackupSize = secondBackupResults.SizeOfOpenedFiles;
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Second backup completed: {secondBackupResults.OpenedFiles} files opened, {secondBackupSize} bytes examined, {secondBackupResults.SizeOfAddedFiles} bytes added ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
if (fsType is not FileSystemType.Unknown)
|
||||
{
|
||||
// Verify the second backup processed significantly fewer files than the first
|
||||
// The second backup should only process modified/new files, not all files
|
||||
Assert.That(secondBackupResults.OpenedFiles, Is.LessThan(firstBackupResults.OpenedFiles),
|
||||
"Incremental backup should open fewer files than the initial backup");
|
||||
Assert.That(secondBackupResults.SizeOfOpenedFiles, Is.LessThan(firstBackupResults.SizeOfOpenedFiles),
|
||||
"Incremental backup should examine less data than the initial backup");
|
||||
}
|
||||
|
||||
// Also verify that the second backup actually modified some files
|
||||
Assert.That(secondBackupResults.ModifiedFiles, Is.GreaterThan(0),
|
||||
"Incremental backup should have added new or modified files");
|
||||
|
||||
// Calculate and log the reduction ratio
|
||||
var fileReductionRatio = (double)secondBackupResults.OpenedFiles / firstBackupResults.OpenedFiles;
|
||||
var sizeReductionRatio = (double)secondBackupResults.SizeOfOpenedFiles / firstBackupResults.SizeOfOpenedFiles;
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"File reduction: {fileReductionRatio:P2} ({secondBackupResults.OpenedFiles}/{firstBackupResults.OpenedFiles}) ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
await TestContext.Progress.WriteLineAsync($"Size reduction: {sizeReductionRatio:P2} ({secondBackupResults.SizeOfOpenedFiles}/{firstBackupResults.SizeOfOpenedFiles}) ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Restore the incremental backup to a fresh disk
|
||||
_diskHelper.CleanupDisk(_restoreImagePath);
|
||||
DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
|
||||
|
||||
restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
|
||||
_diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
|
||||
_diskHelper.Unmount(restoreDrivePath);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Fresh restore disk created for incremental restore ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
var incrementalRestoreResults = await RunRestoreAsync(restoreDrivePath);
|
||||
TestUtils.AssertResults(incrementalRestoreResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"Incremental restore completed successfully ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
|
||||
// Reattach and verify the incremental restore matches the modified source
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
|
||||
restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
|
||||
|
||||
sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
|
||||
CompareDirectories(sourceMounted.First(), restoreMounted.First());
|
||||
|
||||
stopwatch.Stop();
|
||||
await TestContext.Progress.WriteLineAsync($"{fsType} incremental backup and restore verified successfully ({stopwatch.ElapsedMilliseconds}ms)");
|
||||
stopwatch.Restart();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that unallocated space compresses well due to zero-block deduplication.
|
||||
/// Creates a disk with the specified filesystem and sparse data (few files, lots of free space),
|
||||
/// backs it up, and verifies the backup size is significantly smaller than the disk size.
|
||||
/// </summary>
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.NTFS)]
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
|
||||
[Category("DiskImage")]
|
||||
[Category("DiskImageFileSystem")]
|
||||
public async Task Test_FileSystem_UnallocatedSpace_CompressesWell_Async(int size, PartitionTableType tableType, FileSystemType fsType)
|
||||
{
|
||||
var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
|
||||
if (!isSupported)
|
||||
Assert.Ignore(reason);
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"Test: {fsType} Unallocated Space Compression");
|
||||
|
||||
// When testing Unknown filesystem, format source as FAT32 to allow test data generation,
|
||||
// but treat it as unknown during backup
|
||||
var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
|
||||
var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
|
||||
|
||||
// Create source disk with specified partition
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}");
|
||||
|
||||
// Generate minimal test data (sparse data - only a few small files)
|
||||
// This leaves most of the 50MB as unallocated space
|
||||
await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 3, 0, 0, 512);
|
||||
_diskHelper.FlushDisk(sourceDrivePath);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync("Sparse test data generated (few files, lots of free space)");
|
||||
|
||||
// Backup
|
||||
var backupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
|
||||
// When treating filesystem as unknown, the option may not be recognized by the Controller
|
||||
// so we only check for errors, not warnings
|
||||
if (treatFilesystemAsUnknown)
|
||||
{
|
||||
Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
|
||||
"Backup should have no errors");
|
||||
}
|
||||
else
|
||||
{
|
||||
TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
}
|
||||
await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined");
|
||||
|
||||
// Calculate the actual backup size from the target folder
|
||||
var backupFiles = Directory.GetFiles(TARGETFOLDER, "*", SearchOption.AllDirectories);
|
||||
long actualBackupSize = backupFiles.Sum(f => new FileInfo(f).Length);
|
||||
await TestContext.Progress.WriteLineAsync($"Actual backup size on disk: {actualBackupSize} bytes");
|
||||
|
||||
// The backup should be significantly smaller than the full disk size
|
||||
// With zero-block deduplication, unallocated clusters return zeros which compress very well
|
||||
var compressionRatio = (double)actualBackupSize / size;
|
||||
await TestContext.Progress.WriteLineAsync($"Compression ratio: {compressionRatio:P2} ({actualBackupSize}/{size})");
|
||||
|
||||
// Backup should be less than 50% of disk size due to zero deduplication
|
||||
// (This is a conservative estimate - actual ratio should be much better)
|
||||
Assert.That(compressionRatio, Is.LessThan(0.5),
|
||||
"Backup with sparse data should be significantly smaller than full disk size due to zero-block deduplication");
|
||||
|
||||
// Create restore disk and verify restore works
|
||||
var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
|
||||
_diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
|
||||
_diskHelper.Unmount(restoreDrivePath);
|
||||
|
||||
var restoreResults = await RunRestoreAsync(restoreDrivePath);
|
||||
TestUtils.AssertResults(restoreResults);
|
||||
|
||||
// Reattach and verify
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
|
||||
restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
|
||||
|
||||
var fsTypes = new[] { fsType };
|
||||
var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
|
||||
CompareDirectories(sourceMounted.First(), restoreMounted.First());
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"{fsType} unallocated space compression verified successfully");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a completely full disk has all blocks marked as allocated.
|
||||
/// Creates a disk filled with data and verifies the backup reads the full disk.
|
||||
/// </summary>
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.NTFS)]
|
||||
[TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
|
||||
[Category("DiskImage")]
|
||||
[Category("DiskImageFileSystem")]
|
||||
public async Task Test_FileSystem_FullDisk_AllBlocksAllocated_Async(int size, PartitionTableType tableType, FileSystemType fsType)
|
||||
{
|
||||
var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
|
||||
if (!isSupported)
|
||||
Assert.Ignore(reason);
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"Test: {fsType} Full Disk - All Blocks Allocated");
|
||||
|
||||
// When testing Unknown filesystem, format source as FAT32 to allow test data generation,
|
||||
// but treat it as unknown during backup
|
||||
var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
|
||||
var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
|
||||
|
||||
// Create source disk with specified partition (must be at least 32MB for FAT32)
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath} ({size} bytes)");
|
||||
|
||||
// Fill the disk with data (create enough files to fill most of the space)
|
||||
var partitionPath = sourcePartitions.First();
|
||||
|
||||
// Generate many files to fill the disk
|
||||
// Using larger files to fill space quickly
|
||||
// Adjust batch count based on filesystem type - Unknown (raw) mode may need different amounts
|
||||
var batchCount = fsType == FileSystemType.Unknown ? 5 : 10;
|
||||
for (int batch = 0; batch < batchCount; batch++)
|
||||
{
|
||||
await ToolTests.GenerateTestDataAsync(Path.Combine(partitionPath, $"batch_{batch}"), 10, 0, 0, 4096);
|
||||
}
|
||||
_diskHelper.FlushDisk(sourceDrivePath);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync("Disk filled with data");
|
||||
|
||||
// Backup
|
||||
var backupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
|
||||
|
||||
// When treating filesystem as unknown, the option may not be recognized by the Controller
|
||||
// so we only check for errors, not warnings
|
||||
if (treatFilesystemAsUnknown)
|
||||
{
|
||||
Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
|
||||
"Backup should have no errors");
|
||||
}
|
||||
else
|
||||
{
|
||||
TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
}
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined, {backupResults.SizeOfAddedFiles} bytes added");
|
||||
|
||||
// With a full disk, most blocks should be allocated
|
||||
// The backup should have examined a significant portion of the disk
|
||||
// Note: We examine files (blocks), and with a full disk, SizeOfOpenedFiles
|
||||
// should be close to the partition size
|
||||
Assert.That(backupResults.SizeOfOpenedFiles, Is.GreaterThan(size * 0.5),
|
||||
"Full disk backup should examine most of the disk size");
|
||||
|
||||
// Create restore disk and verify restore
|
||||
var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
|
||||
_diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
|
||||
_diskHelper.Unmount(restoreDrivePath);
|
||||
|
||||
var restoreResults = await RunRestoreAsync(restoreDrivePath);
|
||||
TestUtils.AssertResults(restoreResults);
|
||||
|
||||
// Reattach and verify
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
|
||||
restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
|
||||
|
||||
var fsTypes = new[] { fsType };
|
||||
var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
|
||||
CompareDirectories(sourceMounted.First(), restoreMounted.First());
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"{fsType} full disk backup verified successfully");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
|
||||
|
||||
public class FullRoundTripTests : DiskImageTests
|
||||
{
|
||||
#region GPT Single Partition
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_NTFS_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_APFS_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_ExFAT_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Ext2_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Ext3_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_XFS_Async() =>
|
||||
FullRoundTripAsync((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Btrfs_Async() =>
|
||||
FullRoundTripAsync((int)(110 * MiB), PartitionTableType.GPT, [(FileSystemType.Btrfs, 0)]);
|
||||
|
||||
#endregion
|
||||
|
||||
#region MBR Single Partition
|
||||
|
||||
// APFS is only supported on GPT partition tables.
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_MBR_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_ExFAT_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_NTFS_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Ext2_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Ext3_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_XFS_Async() =>
|
||||
FullRoundTripAsync((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Btrfs_Async() =>
|
||||
FullRoundTripAsync((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknown Partition Table
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_Unknown_NoPartitions_Async() =>
|
||||
FullRoundTripAsync((int)(50 * MiB), PartitionTableType.Unknown, []);
|
||||
|
||||
#endregion
|
||||
|
||||
#region GPT Two Partitions
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_FAT32_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_APFS_APFS_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.APFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_HFSPlus_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_APFS_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_FAT32_APFS_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.APFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_ExFAT_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.ExFAT, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_FAT32_ExFAT_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.ExFAT, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_NTFS_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.NTFS, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_NTFS_NTFS_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.NTFS, 50 * MiB),
|
||||
(FileSystemType.NTFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_Ext4_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Ext4_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Ext4_XFS_Async() =>
|
||||
FullRoundTripAsync((int)(360 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.XFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_XFS_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(360 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Btrfs_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(160 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Btrfs, 110 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
#endregion
|
||||
|
||||
#region MBR Two Partitions
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
// APFS is only supported on GPT partition tables.
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_HFSPlus_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_ExFAT_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.ExFAT, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_NTFS_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.NTFS, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Ext4_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_XFS_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(360 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Btrfs_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(160 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.Btrfs, 110 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_XFS_Btrfs_Async() =>
|
||||
FullRoundTripAsync((int)(420 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.Btrfs, 0)
|
||||
]);
|
||||
|
||||
#endregion
|
||||
|
||||
#region GPT Three Partitions
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_FAT32_FAT32_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_APFS_APFS_APFS_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.APFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_NTFS_NTFS_NTFS_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.NTFS, 50 * MiB),
|
||||
(FileSystemType.NTFS, 50 * MiB),
|
||||
(FileSystemType.NTFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_HFSPlus_HFSPlus_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_APFS_HFSPlus_APFS_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.APFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_FAT32_APFS_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_ExFAT_APFS_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.ExFAT, 50 * MiB),
|
||||
(FileSystemType.APFS, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage")]
|
||||
public Task Test_GPT_Ext4_Ext4_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_FAT32_Ext4_XFS_Async() =>
|
||||
FullRoundTripAsync((int)(410 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.XFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Ext4_XFS_Btrfs_Async() =>
|
||||
FullRoundTripAsync((int)(470 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.Btrfs, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_XFS_Ext4_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(410 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_Btrfs_Ext4_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(210 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.Btrfs, 110 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
#endregion
|
||||
|
||||
#region MBR Three Partitions
|
||||
|
||||
// APFS is only supported on GPT partition tables.
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_FAT32_FAT32_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.FAT32, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_HFSPlus_HFSPlus_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_ExFAT_HFSPlus_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.ExFAT, 50 * MiB),
|
||||
(FileSystemType.HFSPlus, 0)
|
||||
]);
|
||||
|
||||
// GPT NTFS for Windows - local-only (tested via single partition)
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_GPT_NTFS_FAT32_ExFAT_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
|
||||
(FileSystemType.NTFS, 50 * MiB),
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.ExFAT, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Ext4_Ext4_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_FAT32_Ext4_XFS_Async() =>
|
||||
FullRoundTripAsync((int)(410 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.FAT32, 50 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.XFS, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Ext4_XFS_Btrfs_Async() =>
|
||||
FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.Btrfs, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_XFS_Btrfs_Ext4_Async() =>
|
||||
FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.XFS, 310 * MiB),
|
||||
(FileSystemType.Btrfs, 110 * MiB),
|
||||
(FileSystemType.Ext4, 0)
|
||||
]);
|
||||
|
||||
[Test, Category("DiskImage"), Category("DiskImageLocal")]
|
||||
public Task Test_MBR_Btrfs_Ext4_XFS_Async() =>
|
||||
FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
|
||||
(FileSystemType.Btrfs, 110 * MiB),
|
||||
(FileSystemType.Ext4, 50 * MiB),
|
||||
(FileSystemType.XFS, 0)
|
||||
]);
|
||||
|
||||
#endregion
|
||||
|
||||
public async Task FullRoundTripAsync(int size, PartitionTableType tableType, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
// Check if all specified file system types are supported before starting the test
|
||||
foreach (var (fsType, partSize) in partitions)
|
||||
{
|
||||
var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
|
||||
if (!isSupported)
|
||||
Assert.Ignore(reason);
|
||||
}
|
||||
|
||||
await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
|
||||
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
|
||||
await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
|
||||
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, partitions);
|
||||
await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
|
||||
|
||||
// Populate source partition with test data
|
||||
foreach (var partition in sourcePartitions)
|
||||
await ToolTests.GenerateTestDataAsync(partition, 10, 5, 2, 1024);
|
||||
_diskHelper.FlushDisk(sourceDrivePath);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync($"Test data generated on source partition(s)");
|
||||
|
||||
// Backup
|
||||
var backupResults = await RunBackupAsync(sourceDrivePath);
|
||||
TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
|
||||
|
||||
// Setup restore target disk image with same geometry
|
||||
var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
|
||||
_diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []);
|
||||
_diskHelper.Unmount(restoreDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync($"Restore disk image created at: {_restoreImagePath}");
|
||||
|
||||
// Restore
|
||||
var restoreResults = await RunRestoreAsync(restoreDrivePath);
|
||||
TestUtils.AssertResults(restoreResults);
|
||||
await TestContext.Progress.WriteLineAsync($"Restore completed successfully");
|
||||
|
||||
// Reattach drives in readonly
|
||||
sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
|
||||
restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
|
||||
await TestContext.Progress.WriteLineAsync($"Source and restore disks re-attached as read-only for verification");
|
||||
|
||||
// Verify partition table matches. Mount before verification, to make disks online on Windows.
|
||||
string[] restorePartitions = [];
|
||||
if (tableType != PartitionTableType.Unknown)
|
||||
{
|
||||
var fsTypes = partitions.Select(p => p.Item1).ToArray();
|
||||
sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
|
||||
}
|
||||
VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync($"Partition table verified to match source");
|
||||
|
||||
// Verify data matches byte-for-byte
|
||||
foreach (var (sourcePartition, restorePartition) in sourcePartitions.Zip(restorePartitions, (s, r) => (s, r)))
|
||||
CompareDirectories(sourcePartition, restorePartition);
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"Restored data verified to match source");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Main;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
|
||||
|
||||
public partial class DiskImageTests : BasicSetupHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a backup operation using the Controller.
|
||||
/// </summary>
|
||||
/// <param name="physicalDrivePath">The physical drive path to backup.</param>
|
||||
/// <param name="treatFilesystemAsUnknown">If true, treats filesystem as unknown (forces raw block-based backup).</param>
|
||||
/// <returns>The backup results.</returns>
|
||||
protected Task<IBackupResults> RunBackupAsync(string physicalDrivePath, bool treatFilesystemAsUnknown = false)
|
||||
{
|
||||
var options = new Dictionary<string, string>(TestOptions);
|
||||
options["enable-module"] = "diskimage";
|
||||
options["concurrency-fileprocessors"] = "1";
|
||||
|
||||
if (!treatFilesystemAsUnknown)
|
||||
{
|
||||
options["diskimage-filesystem-parsed"] = "true";
|
||||
}
|
||||
|
||||
using var c = new Controller("file://" + TARGETFOLDER, options, null);
|
||||
var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}";
|
||||
return c.BackupAsync(new[] { sourceUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a restore operation using the Controller.
|
||||
/// </summary>
|
||||
/// <param name="restoreDrivePath">The physical drive path to restore to.</param>
|
||||
/// <returns>The restore results.</returns>
|
||||
protected Task<IRestoreResults> RunRestoreAsync(string restoreDrivePath)
|
||||
{
|
||||
var options = new Dictionary<string, string>(TestOptions);
|
||||
options["restore-path"] = $"@diskimage://{restoreDrivePath}";
|
||||
options["overwrite"] = "true";
|
||||
options["restore-file-processors"] = "1";
|
||||
|
||||
using var c = new Controller("file://" + TARGETFOLDER, options, null);
|
||||
return c.RestoreAsync(new[] { "*" });
|
||||
}
|
||||
|
||||
protected static IEnumerable<string> GetNonSystemFiles(string directoryPath)
|
||||
{
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
return Directory.EnumerateFiles(directoryPath, "*", options)
|
||||
.Where(f => !string.IsNullOrEmpty(f))
|
||||
.Select(f => Path.GetRelativePath(directoryPath, f))
|
||||
.OrderBy(f => f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively compares two directories for structural and content equality.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">The source directory path.</param>
|
||||
/// <param name="restorePath">The restored directory path.</param>
|
||||
protected void CompareDirectories(string sourcePath, string restorePath)
|
||||
{
|
||||
// Get all files in source, except for system files, which cannot be read directly
|
||||
var sourceFiles = GetNonSystemFiles(sourcePath).ToList();
|
||||
var restoreFiles = GetNonSystemFiles(restorePath).ToList();
|
||||
|
||||
Assert.That(sourceFiles.Count, Is.EqualTo(restoreFiles.Count),
|
||||
$"Number of files in source and restore should match. Source: {sourceFiles.Count}, Restore: {restoreFiles.Count}");
|
||||
Assert.That(sourceFiles.Count, Is.GreaterThan(0),
|
||||
"Source partition should contain files to verify");
|
||||
Assert.That(restoreFiles.Count, Is.GreaterThan(0),
|
||||
"Restore partition should contain files to verify");
|
||||
|
||||
// Check that all source files exist in restore
|
||||
foreach (var relativePath in sourceFiles)
|
||||
{
|
||||
Assert.That(restoreFiles, Does.Contain(relativePath),
|
||||
$"Restored drive should contain file: {relativePath}");
|
||||
}
|
||||
|
||||
// Compare file contents byte-by-byte
|
||||
foreach (var relativeSourceFile in sourceFiles)
|
||||
{
|
||||
var sourceFile = Path.Combine(sourcePath, relativeSourceFile);
|
||||
var restoreFile = Path.Combine(restorePath, relativeSourceFile);
|
||||
|
||||
if (File.Exists(restoreFile))
|
||||
{
|
||||
var sourceBytes = File.ReadAllBytes(sourceFile);
|
||||
var restoreBytes = File.ReadAllBytes(restoreFile);
|
||||
|
||||
Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length),
|
||||
$"File size mismatch for {sourceFile}");
|
||||
Assert.That(restoreBytes, Is.EqualTo(sourceBytes),
|
||||
$"File content mismatch for {sourceFile}");
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Progress.WriteLine($"Verified {sourceFiles.Count} files match between source and restored drives");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the geometry metadata is present and valid.
|
||||
/// </summary>
|
||||
/// <param name="target">The backup target URL.</param>
|
||||
/// <param name="options">The options to use when accessing the backup.</param>
|
||||
/// <param name="partitions">The expected partitions and their sizes.</param>
|
||||
protected async Task VerifyGeometryMetadataAsync(string target, Dictionary<string, string> options, (FileSystemType, long)[] partitions)
|
||||
{
|
||||
await TestContext.Progress.WriteLineAsync("Verifying geometry metadata...");
|
||||
|
||||
using (var c = new Controller("file://" + target, options, null))
|
||||
{
|
||||
var listResults = await c.ListAsync("*");
|
||||
Assert.That(listResults.Files, Is.Not.Null);
|
||||
|
||||
var geometryFile = listResults.Files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
|
||||
Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
|
||||
|
||||
// Restore and parse the geometry.json file
|
||||
var geometryPath = Path.Combine(RESTOREFOLDER, $"geometry.json");
|
||||
try
|
||||
{
|
||||
var restoreOptions = new Dictionary<string, string>(options)
|
||||
{
|
||||
["restore-path"] = RESTOREFOLDER,
|
||||
["overwrite"] = "true"
|
||||
};
|
||||
|
||||
using (var restoreController = new Controller("file://" + target, restoreOptions, null))
|
||||
{
|
||||
var restoreResults = await restoreController.RestoreAsync(new[] { geometryFile!.Path });
|
||||
TestUtils.AssertResults(restoreResults);
|
||||
}
|
||||
|
||||
// Read and parse the geometry.json
|
||||
if (File.Exists(geometryPath))
|
||||
{
|
||||
var json = File.ReadAllText(geometryPath);
|
||||
var geometry = GeometryMetadata.FromJson(json);
|
||||
|
||||
Assert.That(geometry, Is.Not.Null, "geometry.json should deserialize successfully");
|
||||
Assert.That(geometry!.Version, Is.GreaterThan(0), "Geometry version should be set");
|
||||
Assert.That(geometry.Disk, Is.Not.Null, "Disk geometry should be present");
|
||||
Assert.That(geometry.Disk!.Size, Is.GreaterThan(0), "Disk size should be greater than 0");
|
||||
Assert.That(geometry.Disk.SectorSize, Is.GreaterThan(0), "Sector size should be greater than 0");
|
||||
Assert.That(geometry.PartitionTable, Is.Not.Null, "Partition table should be present");
|
||||
Assert.That(geometry.Partitions, Is.Not.Null, "Partitions list should be present");
|
||||
Assert.That(geometry.Partitions!.Count, Is.GreaterThan(0), "Should have at least one partition");
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"Geometry metadata verified: Disk size={geometry.Disk.Size}, " +
|
||||
$"Sector size={geometry.Disk.SectorSize}, Partitions={geometry.Partitions.Count}, " +
|
||||
$"Table type={geometry.PartitionTable!.Type}");
|
||||
|
||||
for (int i = 0; i < geometry.Partitions.Count; i++)
|
||||
{
|
||||
var partition = geometry.Partitions[i];
|
||||
await TestContext.Progress.WriteLineAsync($"Partition {i}: Type={partition.Type}, Start={partition.StartOffset}, Size={partition.Size}");
|
||||
|
||||
Assert.That(partition.Size, Is.GreaterThan(0), $"Partition {i} size should be greater than 0");
|
||||
if (partitions[i].Item2 > 0)
|
||||
{
|
||||
Assert.That(partition.Size, Is.EqualTo(partitions[i].Item2), $"Partition {i} size should match expected");
|
||||
}
|
||||
Assert.That(partition.StartOffset, Is.GreaterThanOrEqualTo(0), $"Partition {i} start offset should be non-negative");
|
||||
Assert.That(partition.Type, Is.Not.Null, $"Partition {i} type should be present");
|
||||
Assert.That(partition.FilesystemType, Is.Not.Null, $"Partition {i} filesystem type should be present");
|
||||
// Type shouldn't be checked until implemented.
|
||||
//Assert.That(partition.FilesystemType, Is.EqualTo(partitions[i].Item1), $"Partition {i} filesystem type should be known");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(geometryPath))
|
||||
{
|
||||
File.Delete(geometryPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the restored partition table matches the source.
|
||||
/// </summary>
|
||||
/// <param name="sourceDrivePath">The physical drive path of the source disk.</param>
|
||||
/// <param name="restoreDrivePath">The physical drive path of the restored disk.</param>
|
||||
protected void VerifyPartitionTableMatches(string sourceDrivePath, string restoreDrivePath)
|
||||
{
|
||||
TestContext.Progress.WriteLine("Verifying partition table matches...");
|
||||
|
||||
// Get source disk details
|
||||
var sourceTable = _diskHelper.GetPartitionTable(sourceDrivePath);
|
||||
TestContext.Progress.WriteLine("Source disk details:\n" + sourceTable);
|
||||
|
||||
// Get restore disk details
|
||||
var restoreTable = _diskHelper.GetPartitionTable(restoreDrivePath);
|
||||
TestContext.Progress.WriteLine("Restore disk details:\n" + restoreTable);
|
||||
|
||||
Assert.AreEqual(sourceTable.Type, restoreTable.Type, "Partition table types should match");
|
||||
Assert.AreEqual(sourceTable.SectorSize, restoreTable.SectorSize, "Sector sizes should match");
|
||||
Assert.AreEqual(sourceTable.Size, restoreTable.Size, "Size of the disks should match");
|
||||
|
||||
// Retrieve partition information
|
||||
var sourcePartitions = _diskHelper.GetPartitions(sourceDrivePath);
|
||||
var restorePartitions = _diskHelper.GetPartitions(restoreDrivePath);
|
||||
|
||||
// Verify partition counts match
|
||||
Assert.AreEqual(sourcePartitions.Length, restorePartitions.Length, "Number of partitions should match");
|
||||
|
||||
// Verify partitions match
|
||||
for (int i = 0; i < sourcePartitions.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(sourcePartitions[i].Type, restorePartitions[i].Type, $"Partition {i} types should match");
|
||||
Assert.AreEqual(sourcePartitions[i].StartOffset, restorePartitions[i].StartOffset, $"Partition {i} offsets should match");
|
||||
Assert.AreEqual(sourcePartitions[i].Size, restorePartitions[i].Size, $"Partition {i} sizes should match");
|
||||
}
|
||||
|
||||
TestContext.Progress.WriteLine($"Partition table verified.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Main;
|
||||
using Duplicati.Proprietary.DiskImage;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class ParsingTests : DiskImageTests
|
||||
{
|
||||
[Test]
|
||||
[Category("DiskImage")]
|
||||
public async Task Test_GeometryMetadata_Verification_Async()
|
||||
{
|
||||
await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
|
||||
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, 100 * MiB);
|
||||
await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
|
||||
|
||||
(FileSystemType, long)[] partitions = [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)];
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, partitions);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
|
||||
|
||||
// Backup
|
||||
var backupResults = await RunBackupAsync(sourceDrivePath);
|
||||
TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
|
||||
// List backup contents and verify geometry.json is present
|
||||
using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null))
|
||||
{
|
||||
var listResults = await c.ListAsync("*");
|
||||
TestUtils.AssertResults(listResults);
|
||||
|
||||
// Check that geometry.json is in the backup
|
||||
var files = listResults.Files;
|
||||
Assert.That(files, Is.Not.Null);
|
||||
var geometryFile = files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
|
||||
Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
|
||||
}
|
||||
|
||||
// Verify geometry metadata contains correct information
|
||||
await VerifyGeometryMetadataAsync(TARGETFOLDER, TestOptions, partitions);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("DiskImage")]
|
||||
public async Task Test_SourceProvider_Enumeration_Async()
|
||||
{
|
||||
await TestContext.Progress.WriteLineAsync("Test: SourceProvider Enumeration");
|
||||
|
||||
// Setup: Create GPT disk with one FAT32 partition
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk image created at: {_sourceImagePath}");
|
||||
|
||||
// Directly instantiate SourceProvider
|
||||
// Note: When directly instantiating SourceProvider, we don't use the @ prefix
|
||||
// The @ prefix is only for Controller to recognize it as a remote source
|
||||
var sourceUrl = $"diskimage://{sourceDrivePath}";
|
||||
using var provider = new SourceProvider(sourceUrl, "", new Dictionary<string, string?>());
|
||||
|
||||
// Initialize the provider
|
||||
await provider.InitializeAsync(CancellationToken.None);
|
||||
|
||||
// Enumerate entries
|
||||
var diskEntries = new List<ISourceProviderEntry>();
|
||||
await foreach (var entry in provider.EnumerateAsync(CancellationToken.None))
|
||||
{
|
||||
diskEntries.Add(entry);
|
||||
await TestContext.Progress.WriteLineAsync($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})");
|
||||
}
|
||||
|
||||
// Verify hierarchy: disk → partition → filesystem → files
|
||||
Assert.That(diskEntries.Count, Is.GreaterThan(0), "Should have at least one entry (the disk)");
|
||||
|
||||
// Fetch the rest of the enumaration.
|
||||
// Check for disk entry
|
||||
var diskEntry = diskEntries.FirstOrDefault(e => e.IsRootEntry);
|
||||
Assert.That(diskEntry, Is.Not.Null, "Should have a root disk entry");
|
||||
|
||||
var entries = new List<ISourceProviderEntry>();
|
||||
await foreach (var entry in diskEntry!.Enumerate(CancellationToken.None))
|
||||
entries.Add(entry);
|
||||
|
||||
await TestContext.Progress.WriteLineAsync($"Total entries found: {entries.Count}");
|
||||
|
||||
// Check for geometry.json
|
||||
var geometryEntry = entries.FirstOrDefault(e => e.Path.EndsWith("geometry.json"));
|
||||
Assert.That(geometryEntry, Is.Not.Null, "Should have geometry.json entry");
|
||||
|
||||
// Check for partition entries
|
||||
var partitionEntries = entries.Where(e => e.Path.Contains("part_")).ToList();
|
||||
Assert.That(partitionEntries.Count, Is.GreaterThan(0), "Should have at least one partition entry");
|
||||
|
||||
// Add all of the filesystems.
|
||||
var fsEntries = new List<ISourceProviderEntry>();
|
||||
foreach (var partitionEntry in partitionEntries)
|
||||
await foreach (var entry in partitionEntry.Enumerate(CancellationToken.None))
|
||||
fsEntries.Add(entry);
|
||||
|
||||
// Check for filesystem entries
|
||||
Assert.That(fsEntries.All(x => x.Path.Contains("fs_")));
|
||||
Assert.That(fsEntries.Count, Is.GreaterThan(0), "Should have at least one filesystem entry");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("DiskImage")]
|
||||
public async Task Test_AutoUnmountOption_RestoreWhileOnline_Async()
|
||||
{
|
||||
await TestContext.Progress.WriteLineAsync("Test: Auto Unmount Option - Restore While Disk Online");
|
||||
|
||||
// Setup source disk image: 100MB, GPT, single FAT32 partition
|
||||
var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
|
||||
var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
|
||||
await TestContext.Progress.WriteLineAsync($"Source disk image created at: {_sourceImagePath}");
|
||||
|
||||
// Generate some test data
|
||||
await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 10, 5, 2, 1024);
|
||||
_diskHelper.FlushDisk(sourceDrivePath);
|
||||
_diskHelper.Unmount(sourceDrivePath);
|
||||
await TestContext.Progress.WriteLineAsync($"Test data generated on source partition");
|
||||
|
||||
// Backup
|
||||
var backupResults = await RunBackupAsync(sourceDrivePath);
|
||||
TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
|
||||
await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
|
||||
|
||||
// Create and attach disk image
|
||||
var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, (100 * MiB));
|
||||
|
||||
// Initialize disk (the restore will overwrite this, but we need it formatted)
|
||||
_diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
|
||||
_diskHelper.Mount(restoreDrivePath, _restoreMountPath);
|
||||
await TestContext.Progress.WriteLineAsync($"Restore disk image created and kept online at: {_restoreImagePath}");
|
||||
|
||||
// First attempt: Restore without auto-unmount option should fail
|
||||
// because the disk is online and in use
|
||||
await TestContext.Progress.WriteLineAsync("Attempting restore without auto-unmount (should fail)...");
|
||||
var options = new Dictionary<string, string>(TestOptions)
|
||||
{
|
||||
["restore-path"] = $"@diskimage://{restoreDrivePath}",
|
||||
["overwrite"] = "true",
|
||||
["restore-file-processors"] = "1",
|
||||
// Explicitly disable auto-unmount (though it's disabled by default)
|
||||
["diskimage-restore-auto-unmount"] = "false"
|
||||
};
|
||||
|
||||
using (var c = new Controller("file://" + TARGETFOLDER, options, null))
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await c.RestoreAsync(["*"]);
|
||||
|
||||
// Verify that the restore failed (has errors)
|
||||
Assert.That(results.Errors.Any(), Is.True,
|
||||
"Restore should fail when target disk is online and auto-unmount is not enabled");
|
||||
await TestContext.Progress.WriteLineAsync($"Restore failed as expected with errors: {string.Join(", ", results.Errors)}");
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Assumed to fail
|
||||
}
|
||||
catch (UserInformationException)
|
||||
{
|
||||
// Assumed to fail
|
||||
}
|
||||
}
|
||||
|
||||
// Second attempt: Restore with auto-unmount option should succeed
|
||||
await TestContext.Progress.WriteLineAsync("Attempting restore with auto-unmount enabled (should succeed)...");
|
||||
|
||||
options["diskimage-restore-auto-unmount"] = "true";
|
||||
using (var c = new Controller("file://" + TARGETFOLDER, options, null))
|
||||
{
|
||||
var results = await c.RestoreAsync(["*"]);
|
||||
|
||||
Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1);
|
||||
}
|
||||
await TestContext.Progress.WriteLineAsync($"Restore completed successfully with auto-unmount option");
|
||||
|
||||
// Mount before verification, to make disks online on Windows.
|
||||
sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true);
|
||||
var restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true);
|
||||
|
||||
// Verify partition table matches
|
||||
VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
|
||||
|
||||
// Verify data matches byte-for-byte
|
||||
var sourcePartition = sourcePartitions.First();
|
||||
var restorePartition = restorePartitions.First();
|
||||
CompareDirectories(sourcePartition, restorePartition);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Duplicati.UnitTest.DiskImage.Helpers;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the DiskImage backup and restore functionality.
|
||||
/// These tests use disk image files managed to test the full backup and restore flow.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImage")]
|
||||
[Platform("Win,MacOsX,Linux")]
|
||||
public partial class DiskImageTests : BasicSetupHelper
|
||||
{
|
||||
protected string _sourceImagePath = null!;
|
||||
protected string _restoreImagePath = null!;
|
||||
protected string _sourceMountPath = null!;
|
||||
protected string _restoreMountPath = null!;
|
||||
protected IDiskImageHelper _diskHelper = null!;
|
||||
|
||||
protected const long MiB = 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the test environment before each test.
|
||||
/// Creates the disk helper and temporary disk image paths.
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void DiskImageSetUp()
|
||||
{
|
||||
// Create the appropriate disk image helper for the current platform
|
||||
_diskHelper = DiskImageHelperFactory.Create();
|
||||
|
||||
// Check for admin privileges
|
||||
if (!_diskHelper.HasRequiredPrivileges())
|
||||
{
|
||||
Assert.Ignore("DiskImage tests require administrator privileges");
|
||||
}
|
||||
|
||||
// Create temp disk image paths
|
||||
var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension();
|
||||
_sourceImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_source_{Guid.NewGuid()}.{extension}");
|
||||
_restoreImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_restore_{Guid.NewGuid()}.{extension}");
|
||||
_sourceMountPath = Path.Combine(DATAFOLDER, $"mnt_source_{Guid.NewGuid()}");
|
||||
_restoreMountPath = Path.Combine(DATAFOLDER, $"mnt_restore_{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(_sourceMountPath);
|
||||
Directory.CreateDirectory(_restoreMountPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the test environment after each test.
|
||||
/// Detaches and deletes disk image files.
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void DiskImageTearDown()
|
||||
{
|
||||
// Cleanup disk images
|
||||
try
|
||||
{
|
||||
_diskHelper.CleanupDisk(_sourceImagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to cleanup source disk image: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_diskHelper.CleanupDisk(_restoreImagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore disk image: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_diskHelper.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Progress.WriteLine($"Warning: Failed to dispose disk helper: {ex.Message}");
|
||||
}
|
||||
|
||||
DiskImageTestHelpers.SafeDeleteFile(_sourceImagePath);
|
||||
DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="NtfsBitmap"/> class.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class NtfsBitmapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid NTFS boot sector with known values for testing.
|
||||
/// </summary>
|
||||
private static byte[] CreateValidBootSector()
|
||||
{
|
||||
var bootSector = new byte[512];
|
||||
|
||||
// OEM ID at offset 0x03 (8 bytes: "NTFS ")
|
||||
var oemId = Encoding.ASCII.GetBytes("NTFS ");
|
||||
oemId.CopyTo(bootSector, 0x03);
|
||||
|
||||
// Bytes per sector at offset 0x0B (512 = 0x0200)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
|
||||
|
||||
// Sectors per cluster at offset 0x0D (8 sectors)
|
||||
bootSector[0x0D] = 8;
|
||||
|
||||
// Total sectors at offset 0x28 (1000 sectors = 125 clusters)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x28, 8), 1000);
|
||||
|
||||
// MFT cluster number at offset 0x30 (cluster 2)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x30, 8), 2);
|
||||
|
||||
// MFT mirror cluster number at offset 0x38 (cluster 100)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x38, 8), 100);
|
||||
|
||||
// Clusters per MFT record at offset 0x40 (-10 means 2^10 = 1024 bytes)
|
||||
bootSector[0x40] = unchecked((byte)-10);
|
||||
|
||||
// Clusters per index block at offset 0x44 (-12 means 2^12 = 4096 bytes)
|
||||
bootSector[0x44] = unchecked((byte)-12);
|
||||
|
||||
// Volume serial number at offset 0x48
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x48, 8), 0x123456789ABCDEF0);
|
||||
|
||||
// Boot sector signature at offset 510
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
|
||||
|
||||
return bootSector;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_FreeCluster_NotAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create all-zero bitmap (all clusters free)
|
||||
var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
|
||||
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(0), "Cluster 0 should be free");
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(10), "Cluster 10 should be free");
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(100), "Cluster 100 should be free");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_AllocatedCluster_IsAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with specific bits set
|
||||
var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
|
||||
// Set bit 0 (cluster 0) - LSB of byte 0
|
||||
bitmapData[0] = 0x01;
|
||||
// Set bit 10 (cluster 10) - bit 2 of byte 1
|
||||
bitmapData[1] = 0x04;
|
||||
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
Assert.IsTrue(bitmap.IsClusterAllocated(0), "Cluster 0 should be allocated");
|
||||
Assert.IsTrue(bitmap.IsClusterAllocated(10), "Cluster 10 should be allocated");
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(1), "Cluster 1 should be free");
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(5), "Cluster 5 should be free");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_MixedAllocation()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with mix of allocated and free clusters
|
||||
var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
|
||||
// Byte 0: bits 0, 2, 4, 6 allocated = 0x55
|
||||
bitmapData[0] = 0x55;
|
||||
// Byte 1: bits 1, 3, 5, 7 allocated = 0xAA
|
||||
bitmapData[1] = 0xAA;
|
||||
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// 8 bits set in first two bytes
|
||||
Assert.AreEqual(8, bitmap.AllocatedClusters, "Should have 8 allocated clusters");
|
||||
Assert.AreEqual(bootSector.TotalClusters - 8, bitmap.FreeClusters, "Free clusters should match");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_AllAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create all-ones bitmap
|
||||
var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
|
||||
for (var i = 0; i < bitmapData.Length; i++)
|
||||
{
|
||||
bitmapData[i] = 0xFF;
|
||||
}
|
||||
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
Assert.AreEqual(0, bitmap.FreeClusters, "All clusters should be allocated");
|
||||
Assert.AreEqual(bootSector.TotalClusters, bitmap.AllocatedClusters, "Allocated clusters should equal total");
|
||||
Assert.IsTrue(bitmap.IsClusterAllocated(0), "Cluster 0 should be allocated");
|
||||
Assert.IsTrue(bitmap.IsClusterAllocated((int)bootSector.TotalClusters - 1), "Last cluster should be allocated");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_ClusterOutOfRange_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// Negative cluster number
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => bitmap.IsClusterAllocated(-1));
|
||||
|
||||
// Cluster number beyond total clusters
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => bitmap.IsClusterAllocated(bootSector.TotalClusters));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => bitmap.IsClusterAllocated(bootSector.TotalClusters + 100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_TotalClusters_Correct()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// Total clusters should be 1000 sectors / 8 sectors per cluster = 125
|
||||
Assert.AreEqual(125, bitmap.TotalClusters, "Total clusters should match boot sector");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_BitOrdering()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap testing LSB-first ordering
|
||||
var bitmapData = new byte[4]; // 32 clusters worth
|
||||
// Bit 0 of byte 0 -> cluster 0
|
||||
bitmapData[0] = 0x01;
|
||||
// Bit 7 of byte 0 -> cluster 7
|
||||
// Bit 0 of byte 1 -> cluster 8
|
||||
bitmapData[1] = 0x01;
|
||||
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
Assert.IsTrue(bitmap.IsClusterAllocated(0), "Bit 0 of byte 0 should be cluster 0");
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(1), "Bit 1 should not be set");
|
||||
Assert.IsFalse(bitmap.IsClusterAllocated(7), "Bit 7 should not be set");
|
||||
Assert.IsTrue(bitmap.IsClusterAllocated(8), "Bit 0 of byte 1 should be cluster 8");
|
||||
|
||||
// Test bit 7 of byte 0
|
||||
bitmapData[0] = 0x80;
|
||||
bitmapData[1] = 0x00;
|
||||
var bitmap2 = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
Assert.IsFalse(bitmap2.IsClusterAllocated(0), "Bit 0 should not be set");
|
||||
Assert.IsTrue(bitmap2.IsClusterAllocated(7), "Bit 7 of byte 0 should be cluster 7");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_FixupArray_Applied()
|
||||
{
|
||||
// Create a fake MFT record with fixup array
|
||||
var recordSize = 1024; // Standard MFT record size
|
||||
var recordData = new byte[recordSize];
|
||||
|
||||
// FILE magic at offset 0
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(recordData.AsSpan(0, 4), 0x454C4946);
|
||||
|
||||
// Fixup offset at 0x04 (offset 48 = 0x30)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x04, 2), 0x30);
|
||||
|
||||
// Fixup count at 0x06 (3 entries = 2 sectors + sequence number)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x06, 2), 3);
|
||||
|
||||
// Set up fixup array at offset 0x30
|
||||
// First entry is sequence number
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x30, 2), 0x1234);
|
||||
// Second entry is original value for sector 1
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x32, 2), 0xABCD);
|
||||
// Third entry is original value for sector 2
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x34, 2), 0xEF01);
|
||||
|
||||
// Place sequence numbers at end of sectors (positions 510 and 1022)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(510, 2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(1022, 2), 0x1234);
|
||||
|
||||
// Apply fixup
|
||||
NtfsBitmap.ApplyFixupArray(recordData);
|
||||
|
||||
// Verify the original values were restored
|
||||
Assert.AreEqual(0xABCD, BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(510, 2)), "Sector 1 original value should be restored");
|
||||
Assert.AreEqual(0xEF01, BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(1022, 2)), "Sector 2 original value should be restored");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_FixupArray_Mismatch_Throws()
|
||||
{
|
||||
// Create a fake MFT record with incorrect fixup values
|
||||
var recordSize = 1024;
|
||||
var recordData = new byte[recordSize];
|
||||
|
||||
// FILE magic
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(recordData.AsSpan(0, 4), 0x454C4946);
|
||||
|
||||
// Fixup offset
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x04, 2), 0x30);
|
||||
|
||||
// Fixup count
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x06, 2), 3);
|
||||
|
||||
// Sequence number
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x30, 2), 0x1234);
|
||||
|
||||
// Put wrong sequence number at end of sector 1
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(510, 2), 0x5678);
|
||||
|
||||
// This should throw due to mismatch
|
||||
Assert.Throws<InvalidDataException>(() => NtfsBitmap.ApplyFixupArray(recordData));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_DataRunParsing_SingleRun()
|
||||
{
|
||||
// Data run: header 0x11 means 1 byte for length, 1 byte for offset
|
||||
// Run length: 0x10 (16 clusters)
|
||||
// Run offset: 0x05 (cluster 5, absolute since first run)
|
||||
var data = new byte[] { 0x11, 0x10, 0x05, 0x00 };
|
||||
|
||||
var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
|
||||
|
||||
Assert.AreEqual(1, runs.Count, "Should have 1 run");
|
||||
Assert.AreEqual(5, runs[0].startCluster, "Start cluster should be 5");
|
||||
Assert.AreEqual(16, runs[0].clusterCount, "Run length should be 16 clusters");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_DataRunParsing_MultipleRuns()
|
||||
{
|
||||
// Multiple data runs
|
||||
// Run 1: 0x11, 0x0A, 0x05 -> 10 clusters starting at cluster 5
|
||||
// Run 2: 0x11, 0x14, 0x0A -> 20 clusters starting at cluster 15 (5 + 10)
|
||||
// Terminator: 0x00
|
||||
var data = new byte[] { 0x11, 0x0A, 0x05, 0x11, 0x14, 0x0A, 0x00 };
|
||||
|
||||
var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
|
||||
|
||||
Assert.AreEqual(2, runs.Count, "Should have 2 runs");
|
||||
Assert.AreEqual(5, runs[0].startCluster, "First run should start at cluster 5");
|
||||
Assert.AreEqual(10, runs[0].clusterCount, "First run should be 10 clusters");
|
||||
Assert.AreEqual(15, runs[1].startCluster, "Second run should start at cluster 15");
|
||||
Assert.AreEqual(20, runs[1].clusterCount, "Second run should be 20 clusters");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_DataRunParsing_NegativeOffset()
|
||||
{
|
||||
// Test negative offset (for fragmented files that go backwards)
|
||||
// Run 1: 0x11, 0x0A, 0x64 -> 10 clusters starting at cluster 100
|
||||
// Run 2: 0x11, 0x05, 0xF6 -> 5 clusters, offset = -10 (0xF6 as signed byte)
|
||||
// Terminator: 0x00
|
||||
var data = new byte[] { 0x11, 0x0A, 0x64, 0x11, 0x05, 0xF6, 0x00 };
|
||||
|
||||
var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
|
||||
|
||||
Assert.AreEqual(2, runs.Count, "Should have 2 runs");
|
||||
Assert.AreEqual(100, runs[0].startCluster, "First run should start at cluster 100");
|
||||
Assert.AreEqual(90, runs[1].startCluster, "Second run should start at cluster 90 (100 - 10)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_DataRunParsing_SparseRun()
|
||||
{
|
||||
// Test sparse run (zero offset means sparse/unallocated extent)
|
||||
// 0x11, 0x0A, 0x00 -> 10 clusters with zero offset = sparse
|
||||
var data = new byte[] { 0x11, 0x0A, 0x00, 0x00 };
|
||||
|
||||
var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
|
||||
|
||||
Assert.AreEqual(1, runs.Count, "Should have 1 run");
|
||||
Assert.AreEqual(0, runs[0].startCluster, "Sparse run should have startCluster = 0");
|
||||
Assert.AreEqual(10, runs[0].clusterCount, "Run should be 10 clusters");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_DataRunParsing_MultibyteValues()
|
||||
{
|
||||
// Test data run with multi-byte values
|
||||
// 0x23, 0x40, 0x42, 0x0F, 0x00 ->
|
||||
// 3 bytes for length, 2 bytes for offset
|
||||
// Length: 0x000F4240 = 1,000,000 clusters
|
||||
// Offset: 0x0040 = 64
|
||||
var data = new byte[] { 0x23, 0x40, 0x42, 0x0F, 0x40, 0x00, 0x00 };
|
||||
|
||||
var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
|
||||
|
||||
Assert.AreEqual(1, runs.Count, "Should have 1 run");
|
||||
Assert.AreEqual(64, runs[0].startCluster, "Start cluster should be 64");
|
||||
Assert.AreEqual(1000000, runs[0].clusterCount, "Run should be 1,000,000 clusters");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_DataRunParsing_InvalidHeader_Throws()
|
||||
{
|
||||
// Invalid header: 0x90 means 0 bytes for length (invalid)
|
||||
var data = new byte[] { 0x90, 0x10, 0x05, 0x00 };
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => NtfsBitmap.ParseDataRuns(data.AsSpan(), 0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBitmap_NullBitmapData_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => new NtfsBitmap(bootSector, null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="NtfsBootSector"/> parser.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class NtfsBootSectorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid NTFS boot sector with known values for testing.
|
||||
/// </summary>
|
||||
private static byte[] CreateValidBootSector()
|
||||
{
|
||||
var bootSector = new byte[512];
|
||||
|
||||
// OEM ID at offset 0x03 (8 bytes: "NTFS ")
|
||||
var oemId = Encoding.ASCII.GetBytes("NTFS ");
|
||||
oemId.CopyTo(bootSector, 0x03);
|
||||
|
||||
// Bytes per sector at offset 0x0B (512 = 0x0200)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
|
||||
|
||||
// Sectors per cluster at offset 0x0D (8 sectors)
|
||||
bootSector[0x0D] = 8;
|
||||
|
||||
// Total sectors at offset 0x28 (2,000,000 sectors)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x28, 8), 2_000_000);
|
||||
|
||||
// MFT cluster number at offset 0x30 (cluster 100)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x30, 8), 100);
|
||||
|
||||
// MFT mirror cluster number at offset 0x38 (cluster 125000)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x38, 8), 125_000);
|
||||
|
||||
// Clusters per MFT record at offset 0x40 (-10 means 2^10 = 1024 bytes)
|
||||
bootSector[0x40] = unchecked((byte)-10);
|
||||
|
||||
// Clusters per index block at offset 0x44 (-12 means 2^12 = 4096 bytes)
|
||||
bootSector[0x44] = unchecked((byte)-12);
|
||||
|
||||
// Volume serial number at offset 0x48
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x48, 8), 0x123456789ABCDEF0);
|
||||
|
||||
// Boot sector signature at offset 510: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
|
||||
|
||||
return bootSector;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_ParseValidBootSector()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
Assert.AreEqual(512, bootSector.BytesPerSector);
|
||||
Assert.AreEqual(8, bootSector.SectorsPerCluster);
|
||||
Assert.AreEqual(2_000_000ul, bootSector.TotalSectors);
|
||||
Assert.AreEqual(100ul, bootSector.MftClusterNumber);
|
||||
Assert.AreEqual(125_000ul, bootSector.MftMirrorClusterNumber);
|
||||
Assert.AreEqual(-10, bootSector.ClustersPerMftRecord);
|
||||
Assert.AreEqual(-12, bootSector.ClustersPerIndexBlock);
|
||||
Assert.AreEqual(0x123456789ABCDEF0ul, bootSector.VolumeSerialNumber);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_InvalidSignature_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Change the signature to an invalid value
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(510, 2), 0x1234);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new NtfsBootSector(bootSectorData));
|
||||
StringAssert.Contains("signature", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_InvalidOemId_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Change OEM ID to "FAT32 "
|
||||
var oemId = Encoding.ASCII.GetBytes("FAT32 ");
|
||||
oemId.CopyTo(bootSectorData, 0x03);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new NtfsBootSector(bootSectorData));
|
||||
StringAssert.Contains("OEM ID", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_InvalidBytesPerSector_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Set bytes per sector to 768 (not a power of 2)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), 768);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new NtfsBootSector(bootSectorData));
|
||||
StringAssert.Contains("BytesPerSector", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_InvalidSectorsPerCluster_Throws()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// Set sectors per cluster to 3 (not a power of 2)
|
||||
bootSectorData[0x0D] = 3;
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new NtfsBootSector(bootSectorData));
|
||||
StringAssert.Contains("SectorsPerCluster", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_DerivedProperties_Correct()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// ClusterSize = BytesPerSector * SectorsPerCluster = 512 * 8 = 4096
|
||||
Assert.AreEqual(4096, bootSector.ClusterSize);
|
||||
|
||||
// MftRecordSize = 2^10 = 1024 (ClustersPerMftRecord = -10)
|
||||
Assert.AreEqual(1024, bootSector.MftRecordSize);
|
||||
|
||||
// MftByteOffset = MftClusterNumber * ClusterSize = 100 * 4096 = 409600
|
||||
Assert.AreEqual(409600, bootSector.MftByteOffset);
|
||||
|
||||
// TotalClusters = TotalSectors / SectorsPerCluster = 2,000,000 / 8 = 250,000
|
||||
Assert.AreEqual(250_000, bootSector.TotalClusters);
|
||||
|
||||
// TotalSize = TotalSectors * BytesPerSector = 2,000,000 * 512 = 1,024,000,000
|
||||
Assert.AreEqual(1_024_000_000, bootSector.TotalSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_NegativeClustersPerMftRecord()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// -10 should yield 1024-byte records (2^10)
|
||||
bootSectorData[0x40] = unchecked((byte)-10);
|
||||
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
Assert.AreEqual(1024, bootSector.MftRecordSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_PositiveClustersPerMftRecord()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
// 2 should yield 2 * ClusterSize = 2 * 4096 = 8192 bytes
|
||||
bootSectorData[0x40] = 2;
|
||||
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
Assert.AreEqual(8192, bootSector.MftRecordSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_TooSmallBuffer_Throws()
|
||||
{
|
||||
var smallBuffer = new byte[256];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new NtfsBootSector(smallBuffer));
|
||||
StringAssert.Contains("512", ex!.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_ValidBytesPerSectorValues()
|
||||
{
|
||||
ushort[] validValues = { 512, 1024, 2048, 4096 };
|
||||
|
||||
foreach (var bytesPerSector in validValues)
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), bytesPerSector);
|
||||
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
Assert.AreEqual(bytesPerSector, bootSector.BytesPerSector);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_ValidSectorsPerClusterValues()
|
||||
{
|
||||
byte[] validValues = { 1, 2, 4, 8, 16, 32, 64, 128 };
|
||||
|
||||
foreach (var sectorsPerCluster in validValues)
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
bootSectorData[0x0D] = sectorsPerCluster;
|
||||
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
Assert.AreEqual(sectorsPerCluster, bootSector.SectorsPerCluster);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsBootSector_LargerBuffer_Works()
|
||||
{
|
||||
// Test that a buffer larger than 512 bytes works (e.g., 4KB sector size)
|
||||
var bootSectorData = new byte[4096];
|
||||
var validData = CreateValidBootSector();
|
||||
validData.CopyTo(bootSectorData, 0);
|
||||
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
Assert.AreEqual(512, bootSector.BytesPerSector);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="NtfsFilesystem"/> class and related types.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class NtfsFilesystemTests
|
||||
{
|
||||
[Test]
|
||||
public void Test_NtfsFilesystemMetadata_Properties()
|
||||
{
|
||||
var metadata = new NtfsFilesystemMetadata
|
||||
{
|
||||
BlockSize = 1024 * 1024,
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500,
|
||||
MftRecordSize = 1024
|
||||
};
|
||||
|
||||
Assert.AreEqual(1024 * 1024, metadata.BlockSize);
|
||||
Assert.AreEqual(4096, metadata.ClusterSize);
|
||||
Assert.AreEqual(1000, metadata.TotalClusters);
|
||||
Assert.AreEqual(500, metadata.AllocatedClusters);
|
||||
Assert.AreEqual(500, metadata.FreeClusters);
|
||||
Assert.AreEqual(1024, metadata.MftRecordSize);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFilesystemMetadata_RecordEquality()
|
||||
{
|
||||
var metadata1 = new NtfsFilesystemMetadata
|
||||
{
|
||||
BlockSize = 1024 * 1024,
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500,
|
||||
MftRecordSize = 1024
|
||||
};
|
||||
|
||||
var metadata2 = new NtfsFilesystemMetadata
|
||||
{
|
||||
BlockSize = 1024 * 1024,
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500,
|
||||
MftRecordSize = 1024
|
||||
};
|
||||
|
||||
var metadata3 = new NtfsFilesystemMetadata
|
||||
{
|
||||
BlockSize = 512 * 1024, // Different
|
||||
ClusterSize = 4096,
|
||||
TotalClusters = 1000,
|
||||
AllocatedClusters = 500,
|
||||
FreeClusters = 500,
|
||||
MftRecordSize = 1024
|
||||
};
|
||||
|
||||
Assert.AreEqual(metadata1, metadata2);
|
||||
Assert.AreNotEqual(metadata1, metadata3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFilesystemMetadata_HasJournal_True()
|
||||
{
|
||||
var metadata = new NtfsFilesystemMetadata();
|
||||
|
||||
Assert.IsTrue(metadata.HasJournal, "HasJournal should always be true for NTFS");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFile_DefaultValues()
|
||||
{
|
||||
var file = new NtfsFile();
|
||||
|
||||
Assert.IsNull(file.Path);
|
||||
Assert.IsNull(file.Address);
|
||||
Assert.AreEqual(0, file.Size);
|
||||
Assert.IsFalse(file.IsDirectory);
|
||||
Assert.IsNull(file.LastModified);
|
||||
Assert.IsFalse(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFile_WithValues()
|
||||
{
|
||||
var timestamp = new DateTime(2023, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var file = new NtfsFile
|
||||
{
|
||||
Path = "test_block",
|
||||
Address = 1024,
|
||||
Size = 1024 * 1024,
|
||||
LastModified = timestamp,
|
||||
IsAllocated = true
|
||||
};
|
||||
|
||||
Assert.AreEqual("test_block", file.Path);
|
||||
Assert.AreEqual(1024, file.Address);
|
||||
Assert.AreEqual(1024 * 1024, file.Size);
|
||||
Assert.IsFalse(file.IsDirectory);
|
||||
Assert.AreEqual(timestamp, file.LastModified);
|
||||
Assert.IsTrue(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFile_ImplementsIFile()
|
||||
{
|
||||
var file = new NtfsFile();
|
||||
Assert.IsInstanceOf<IFile>(file);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFile_LastModified_Nullable()
|
||||
{
|
||||
var file1 = new NtfsFile { LastModified = DateTime.UtcNow };
|
||||
var file2 = new NtfsFile { LastModified = null };
|
||||
|
||||
Assert.IsTrue(file1.LastModified.HasValue);
|
||||
Assert.IsFalse(file2.LastModified.HasValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFile_IsAllocated_FalseByDefault()
|
||||
{
|
||||
var file = new NtfsFile();
|
||||
Assert.IsFalse(file.IsAllocated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsFile_IsAllocated_CanBeTrue()
|
||||
{
|
||||
var file = new NtfsFile { IsAllocated = true };
|
||||
Assert.IsTrue(file.IsAllocated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="NtfsMftWalker"/> class.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class NtfsMftWalkerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid NTFS boot sector with known values for testing.
|
||||
/// </summary>
|
||||
private static byte[] CreateValidBootSector()
|
||||
{
|
||||
var bootSector = new byte[512];
|
||||
|
||||
// OEM ID at offset 0x03 (8 bytes: "NTFS ")
|
||||
var oemId = Encoding.ASCII.GetBytes("NTFS ");
|
||||
oemId.CopyTo(bootSector, 0x03);
|
||||
|
||||
// Bytes per sector at offset 0x0B (512 = 0x0200)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
|
||||
|
||||
// Sectors per cluster at offset 0x0D (8 sectors = 4096 bytes per cluster)
|
||||
bootSector[0x0D] = 8;
|
||||
|
||||
// Total sectors at offset 0x28 (10000 sectors = 1250 clusters)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x28, 8), 10000);
|
||||
|
||||
// MFT cluster number at offset 0x30 (cluster 4)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x30, 8), 4);
|
||||
|
||||
// MFT mirror cluster number at offset 0x38 (cluster 500)
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x38, 8), 500);
|
||||
|
||||
// Clusters per MFT record at offset 0x40 (-10 means 2^10 = 1024 bytes)
|
||||
bootSector[0x40] = unchecked((byte)-10);
|
||||
|
||||
// Clusters per index block at offset 0x44 (-12 means 2^12 = 4096 bytes)
|
||||
bootSector[0x44] = unchecked((byte)-12);
|
||||
|
||||
// Volume serial number at offset 0x48
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x48, 8), 0x123456789ABCDEF0);
|
||||
|
||||
// Boot sector signature at offset 510
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
|
||||
|
||||
return bootSector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates bitmap data from an array of allocation flags.
|
||||
/// </summary>
|
||||
private static byte[] CreateBitmapData(params bool[] allocated)
|
||||
{
|
||||
var byteCount = (allocated.Length + 7) / 8;
|
||||
var result = new byte[byteCount];
|
||||
|
||||
for (var i = 0; i < allocated.Length; i++)
|
||||
if (allocated[i])
|
||||
{
|
||||
var byteIndex = i / 8;
|
||||
var bitIndex = i % 8;
|
||||
result[byteIndex] |= (byte)(1 << bitIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an MFT record with the specified attributes.
|
||||
/// </summary>
|
||||
private static byte[] CreateMftRecord(
|
||||
long fileTimestamp,
|
||||
List<(long offset, long length)>? dataRuns = null,
|
||||
bool inUse = true)
|
||||
{
|
||||
var recordSize = 1024; // Typical MFT record size
|
||||
var record = new byte[recordSize];
|
||||
|
||||
// MFT Record Header:
|
||||
// 0x00: Magic "FILE" (4 bytes)
|
||||
// 0x04: Fixup offset (2 bytes)
|
||||
// 0x06: Fixup count (2 bytes) - number of entries (also number of sectors)
|
||||
// 0x08: LSN (8 bytes)
|
||||
// 0x10: Sequence number (2 bytes)
|
||||
// 0x12: Hard link count (2 bytes)
|
||||
// 0x14: Offset to first attribute (2 bytes)
|
||||
// 0x16: Flags (2 bytes) - 0x01 = in use
|
||||
// 0x18: Used size (4 bytes)
|
||||
// 0x1C: Allocated size (4 bytes)
|
||||
// 0x20: Base file record (8 bytes)
|
||||
// 0x28: Next attribute ID (2 bytes)
|
||||
// 0x2A: Align to 8 bytes
|
||||
// 0x30: MFT record number (8 bytes) - Win2K+ only
|
||||
|
||||
// Write FILE magic
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(0, 4), 0x454C4946); // "FILE"
|
||||
|
||||
// Write fixup offset (at 0x04) - point to end of record for fixup array
|
||||
var fixupOffset = 48; // After header
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x04, 2), (ushort)fixupOffset);
|
||||
|
||||
// Write fixup count (at 0x06) - 3 entries for 1024-byte record (2 sectors)
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x06, 2), 3);
|
||||
|
||||
// Write first attribute offset (at 0x14) - after header (48 bytes)
|
||||
var firstAttributeOffset = 56; // After header and some padding
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x14, 2), (ushort)firstAttributeOffset);
|
||||
|
||||
// Write flags (at 0x16) - 0x01 = in use
|
||||
var flags = inUse ? (ushort)0x01 : (ushort)0x00;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x16, 2), flags);
|
||||
|
||||
// Initialize fixup array
|
||||
// First entry is the sequence number
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(fixupOffset, 2), 0x1234);
|
||||
// Next entries are the original values for each sector's last 2 bytes
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(fixupOffset + 2, 2), 0x0000);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(fixupOffset + 4, 2), 0x0000);
|
||||
|
||||
// Write fixup signature bytes at end of each 512-byte sector
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(512 - 2, 2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(1024 - 2, 2), 0x1234);
|
||||
|
||||
var currentOffset = firstAttributeOffset;
|
||||
|
||||
// Add $STANDARD_INFORMATION attribute (0x10)
|
||||
// Resident attribute structure:
|
||||
// 0x00: Type (4 bytes)
|
||||
// 0x04: Length (4 bytes)
|
||||
// 0x08: Non-resident flag (1 byte)
|
||||
// 0x09: Name length (1 byte)
|
||||
// 0x0A: Name offset (2 bytes)
|
||||
// 0x0C: Flags (2 bytes)
|
||||
// 0x0E: Attribute ID (2 bytes)
|
||||
// 0x10: Data length (4 bytes)
|
||||
// 0x14: Data offset (2 bytes)
|
||||
// 0x16: Resident flags (1 byte)
|
||||
// 0x17: Padding (1 byte)
|
||||
// Data follows
|
||||
|
||||
var stdInfoAttrSize = 72; // Header + data
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset, 4), 0x10); // $STANDARD_INFORMATION
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 4, 4), (uint)stdInfoAttrSize);
|
||||
record[currentOffset + 8] = 0x00; // Resident
|
||||
record[currentOffset + 9] = 0x00; // No name
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0A, 2), 0x18); // Name offset
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0C, 2), 0x0000); // Flags
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0E, 2), 0x0000); // Attribute ID
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 0x10, 4), 48); // Data length
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x14, 2), 0x18); // Data offset
|
||||
record[currentOffset + 0x16] = 0x00; // Resident flags
|
||||
record[currentOffset + 0x17] = 0x00; // Padding
|
||||
|
||||
// $STANDARD_INFORMATION data:
|
||||
// 0x00: Creation time (8 bytes)
|
||||
// 0x08: Modification time (8 bytes)
|
||||
// 0x10: MFT modification time (8 bytes)
|
||||
// 0x18: Access time (8 bytes)
|
||||
// 0x20: DOS flags (4 bytes)
|
||||
// 0x24: Max versions (4 bytes)
|
||||
// 0x28: Version (4 bytes)
|
||||
// 0x2C: Class ID (4 bytes)
|
||||
// 0x30: Owner ID (4 bytes) - Win2K+
|
||||
// 0x34: Security ID (4 bytes) - Win2K+
|
||||
// 0x38: Quota charged (8 bytes) - Win2K+
|
||||
// 0x40: Update sequence number (8 bytes) - Win2K+
|
||||
|
||||
var stdInfoDataOffset = currentOffset + 0x18;
|
||||
var filetime = fileTimestamp == 0 ? 0 : fileTimestamp;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x00, 8), filetime); // Creation
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x08, 8), filetime); // Modification
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x10, 8), filetime); // MFT modification
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x18, 8), filetime); // Access
|
||||
|
||||
currentOffset += stdInfoAttrSize;
|
||||
|
||||
// Add $DATA attribute (0x80) if data runs are provided
|
||||
if (dataRuns != null && dataRuns.Count > 0)
|
||||
{
|
||||
// Non-resident $DATA attribute
|
||||
// Header:
|
||||
// 0x00: Attribute type (4 bytes)
|
||||
// 0x04: Attribute length (4 bytes)
|
||||
// 0x08: Non-resident flag (1 byte)
|
||||
// 0x09: Name length (1 byte)
|
||||
// 0x0A: Name offset (2 bytes)
|
||||
// 0x0C: Flags (2 bytes)
|
||||
// 0x0E: Attribute ID (2 bytes)
|
||||
// 0x10: Starting VCN (8 bytes)
|
||||
// 0x18: Ending VCN (8 bytes)
|
||||
// 0x20: Run offset (2 bytes) - offset to data run list from start of attribute
|
||||
// 0x22: Compression unit size (2 bytes)
|
||||
// 0x24: Padding (4 bytes)
|
||||
// 0x28: Allocated size (8 bytes)
|
||||
// 0x30: Data size (8 bytes)
|
||||
// 0x38: Initialized size (8 bytes)
|
||||
// 0x40: Data runs start at offset specified by run offset field. If the attribute is named, then the name starts here. Otherwise, the data runs start here.
|
||||
|
||||
var attrHeaderSize = 0x40;
|
||||
var dataRunBytes = CreateDataRunBytes(dataRuns);
|
||||
var attrSize = attrHeaderSize + dataRunBytes.Length;
|
||||
|
||||
// Align attribute size to 8 bytes
|
||||
attrSize = (attrSize + 7) & ~7;
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset, 4), 0x80); // $DATA
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 4, 4), (uint)attrSize);
|
||||
record[currentOffset + 8] = 0x01; // Non-resident
|
||||
record[currentOffset + 9] = 0x00; // No name
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0A, 2), 0x40); // Name offset
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0C, 2), 0); // Flags
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0E, 2), 0); // Attribute ID
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x10, 8), 0); // Starting VCN
|
||||
|
||||
// Calculate ending VCN from data runs
|
||||
var totalClusters = dataRuns.Sum(r => r.length);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x18, 8), totalClusters - 1); // Ending VCN
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x20, 2), (ushort)attrHeaderSize); // Run offset
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x22, 2), 0); // Compression unit size
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 0x24, 4), 0); // Padding
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x28, 8), totalClusters * 4096); // Allocated size
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x30, 8), totalClusters * 4096); // Data size
|
||||
BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x38, 8), totalClusters * 4096); // Initialized size
|
||||
|
||||
// Copy data runs
|
||||
dataRunBytes.CopyTo(record, currentOffset + attrHeaderSize);
|
||||
|
||||
currentOffset += attrSize;
|
||||
}
|
||||
|
||||
// Add end-of-attributes marker (0xFFFFFFFF)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset, 4), 0xFFFFFFFF);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates data run bytes from a list of (offset, length) pairs.
|
||||
/// </summary>
|
||||
private static byte[] CreateDataRunBytes(List<(long offset, long length)> runs)
|
||||
{
|
||||
var result = new List<byte>();
|
||||
|
||||
long currentCluster = 0;
|
||||
foreach (var (offset, length) in runs)
|
||||
{
|
||||
var relativeOffset = offset - currentCluster;
|
||||
currentCluster = offset;
|
||||
|
||||
// Determine byte counts
|
||||
var lengthBytes = GetByteCount(length);
|
||||
var offsetBytes = GetByteCountSigned(relativeOffset);
|
||||
|
||||
// Header byte: high nibble = offset bytes, low nibble = length bytes
|
||||
result.Add((byte)((offsetBytes << 4) | lengthBytes));
|
||||
|
||||
// Write length (little-endian)
|
||||
for (var i = 0; i < lengthBytes; i++)
|
||||
result.Add((byte)((length >> (i * 8)) & 0xFF));
|
||||
|
||||
// Write offset (little-endian)
|
||||
for (var i = 0; i < offsetBytes; i++)
|
||||
result.Add((byte)((relativeOffset >> (i * 8)) & 0xFF));
|
||||
}
|
||||
|
||||
// End marker
|
||||
result.Add(0x00);
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static int GetByteCount(long value)
|
||||
{
|
||||
if (value == 0) return 0;
|
||||
if (value <= 0xFF) return 1;
|
||||
if (value <= 0xFFFF) return 2;
|
||||
if (value <= 0xFFFFFF) return 3;
|
||||
if (value <= 0xFFFFFFFF) return 4;
|
||||
if (value <= 0xFFFFFFFFFF) return 5;
|
||||
if (value <= 0xFFFFFFFFFFFF) return 6;
|
||||
if (value <= 0xFFFFFFFFFFFFFF) return 7;
|
||||
return 8;
|
||||
}
|
||||
|
||||
private static int GetByteCountSigned(long value)
|
||||
{
|
||||
if (value == 0) return 0;
|
||||
if (value >= -0x80 && value <= 0x7F) return 1;
|
||||
if (value >= -0x8000 && value <= 0x7FFF) return 2;
|
||||
if (value >= -0x800000 && value <= 0x7FFFFF) return 3;
|
||||
if (value >= -0x80000000 && value <= 0x7FFFFFFF) return 4;
|
||||
if (value >= -0x8000000000L && value <= 0x7FFFFFFFFFL) return 5;
|
||||
if (value >= -0x800000000000L && value <= 0x7FFFFFFFFFFFL) return 6;
|
||||
if (value >= -0x80000000000000L && value <= 0x7FFFFFFFFFFFFFL) return 7;
|
||||
return 8;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsMftWalker_ParseFileTime_Correct()
|
||||
{
|
||||
// Create a known DateTime and convert to FILETIME
|
||||
var expectedDate = new DateTime(2024, 1, 15, 12, 30, 0, DateTimeKind.Utc);
|
||||
var filetime = expectedDate.ToFileTimeUtc();
|
||||
|
||||
var result = NtfsMftWalker.ParseFileTime(filetime);
|
||||
|
||||
Assert.AreEqual(expectedDate.Year, result.Year);
|
||||
Assert.AreEqual(expectedDate.Month, result.Month);
|
||||
Assert.AreEqual(expectedDate.Day, result.Day);
|
||||
Assert.AreEqual(expectedDate.Hour, result.Hour);
|
||||
Assert.AreEqual(expectedDate.Minute, result.Minute);
|
||||
Assert.AreEqual(expectedDate.Second, result.Second);
|
||||
Assert.AreEqual(DateTimeKind.Utc, result.Kind);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsMftWalker_ParseFileTime_Zero_ReturnsEpoch()
|
||||
{
|
||||
var result = NtfsMftWalker.ParseFileTime(0);
|
||||
|
||||
Assert.AreEqual(DateTime.UnixEpoch, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_SkipDeletedRecords()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// MFT record 0 describes the MFT itself - place at cluster 100
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(100, 10)],
|
||||
inUse: true);
|
||||
|
||||
// MFT record 1 - deleted (not in use)
|
||||
var mftRecord1 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(20, 5)], // Data at cluster 20, 5 clusters
|
||||
inUse: false);
|
||||
|
||||
var recordCount = 0;
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
recordCount++;
|
||||
return recordNumber switch
|
||||
{
|
||||
0 => mftRecord0,
|
||||
1 => mftRecord1,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
|
||||
// Clusters from deleted record should NOT be in the map
|
||||
// But system metafile clusters should be there
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.Count > 0);
|
||||
|
||||
// Cluster 20-24 (from deleted record) should NOT be in the map
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(20), "Deleted record's clusters should not be in map");
|
||||
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(24), "Deleted record's clusters should not be in map");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_ClusterTimestampMap_Correct()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// FILETIME for 2023-06-01 10:00:00 UTC
|
||||
var timestamp1 = 133303440000000000L;
|
||||
|
||||
// MFT record 0 describes the MFT itself - place at cluster 100
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(100, 10)],
|
||||
inUse: true);
|
||||
|
||||
// MFT record 1 - file with data at clusters 20-24
|
||||
var mftRecord1 = CreateMftRecord(
|
||||
timestamp1,
|
||||
[(20, 5)], // Data at cluster 20, 5 clusters
|
||||
inUse: true);
|
||||
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
return recordNumber switch
|
||||
{
|
||||
0 => mftRecord0,
|
||||
1 => mftRecord1,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
|
||||
// Check that clusters 20-24 are mapped to the correct timestamp
|
||||
var expectedTime = NtfsMftWalker.ParseFileTime(timestamp1);
|
||||
|
||||
for (var cluster = 20; cluster < 25; cluster++)
|
||||
{
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"Cluster {cluster} should be in map");
|
||||
Assert.AreEqual(expectedTime, walker.ClusterToTimestampMap[cluster], $"Cluster {cluster} should have correct timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_SystemMetafiles_UseCurrentTimestamp()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData(Enumerable.Repeat(true, (int)bootSector.TotalClusters).ToArray());
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// MFT record 0 describes the MFT itself
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(4, 10)], // MFT at cluster 4, 10 clusters
|
||||
inUse: true);
|
||||
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
return recordNumber == 0 ? mftRecord0 : null;
|
||||
}
|
||||
|
||||
var before = DateTime.UtcNow.AddSeconds(-1);
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
var after = DateTime.UtcNow.AddSeconds(1);
|
||||
|
||||
// System metafiles (records 0-23) should have timestamps close to now
|
||||
// The MFT is at cluster 4 with 10 clusters (4-13)
|
||||
var mftStartCluster = bootSector.MftByteOffset / bootSector.ClusterSize;
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var cluster = mftStartCluster + i;
|
||||
if (walker.ClusterToTimestampMap.TryGetValue(cluster, out var timestamp))
|
||||
{
|
||||
Assert.GreaterOrEqual(timestamp, before, $"Cluster {cluster} timestamp should be >= before");
|
||||
Assert.LessOrEqual(timestamp, after, $"Cluster {cluster} timestamp should be <= after");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_OverlappingClusters_UsesMostRecentTimestamp()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData(Enumerable.Repeat(true, (int)bootSector.TotalClusters).ToArray());
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// FILETIMEs - older and newer
|
||||
var olderTimestamp = 133000000000000000L; // ~2022
|
||||
var newerTimestamp = 133500000000000000L; // ~2024
|
||||
|
||||
// MFT record 0 describes the MFT itself - place at cluster 100 to avoid overlap with file data
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(100, 20)],
|
||||
inUse: true);
|
||||
|
||||
// Record 1 - file with data at clusters 30-34, older timestamp
|
||||
// Placed far from MFT at cluster 100 to avoid overlap
|
||||
var mftRecord1 = CreateMftRecord(
|
||||
olderTimestamp,
|
||||
[(30, 5)],
|
||||
inUse: true);
|
||||
|
||||
// Record 2 - file with data at clusters 32-36 (overlaps with record 1), newer timestamp
|
||||
var mftRecord2 = CreateMftRecord(
|
||||
newerTimestamp,
|
||||
[(32, 5)],
|
||||
inUse: true);
|
||||
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
return recordNumber switch
|
||||
{
|
||||
0 => mftRecord0,
|
||||
1 => mftRecord1,
|
||||
2 => mftRecord2,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
|
||||
// Clusters 30-31 should have older timestamp (only from record 1)
|
||||
var olderTime = NtfsMftWalker.ParseFileTime(olderTimestamp);
|
||||
var newerTime = NtfsMftWalker.ParseFileTime(newerTimestamp);
|
||||
|
||||
Assert.AreEqual(olderTime, walker.ClusterToTimestampMap[30], "Cluster 30 should have older timestamp");
|
||||
Assert.AreEqual(olderTime, walker.ClusterToTimestampMap[31], "Cluster 31 should have older timestamp");
|
||||
|
||||
// Clusters 32-34 should have newer timestamp (overlapping, newer wins)
|
||||
Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[32], "Cluster 32 should have newer timestamp");
|
||||
Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[33], "Cluster 33 should have newer timestamp");
|
||||
Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[34], "Cluster 34 should have newer timestamp");
|
||||
|
||||
// Clusters 35-36 should have newer timestamp (only from record 2)
|
||||
Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[35], "Cluster 35 should have newer timestamp");
|
||||
Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[36], "Cluster 36 should have newer timestamp");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_DataRunParsing_MultipleRuns()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
var timestamp = 133303440000000000L; // 2023-06-01 10:00:00 UTC
|
||||
|
||||
// MFT record 0 describes the MFT itself - place at cluster 100
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(100, 20)],
|
||||
inUse: true);
|
||||
|
||||
// Record with multiple data runs: clusters 30-34 and 40-44
|
||||
var mftRecord1 = CreateMftRecord(
|
||||
timestamp,
|
||||
new List<(long offset, long length)>
|
||||
{
|
||||
(30, 5), // First run: clusters 30-34
|
||||
(40, 5) // Second run: clusters 40-44
|
||||
},
|
||||
inUse: true);
|
||||
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
return recordNumber switch
|
||||
{
|
||||
0 => mftRecord0,
|
||||
1 => mftRecord1,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
|
||||
var expectedTime = NtfsMftWalker.ParseFileTime(timestamp);
|
||||
|
||||
// Check first run (clusters 30-34)
|
||||
for (var cluster = 30; cluster < 35; cluster++)
|
||||
{
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"Cluster {cluster} should be in map");
|
||||
Assert.AreEqual(expectedTime, walker.ClusterToTimestampMap[cluster], $"Cluster {cluster} should have correct timestamp");
|
||||
}
|
||||
|
||||
// Check second run (clusters 40-44)
|
||||
for (var cluster = 40; cluster < 45; cluster++)
|
||||
{
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"Cluster {cluster} should be in map");
|
||||
Assert.AreEqual(expectedTime, walker.ClusterToTimestampMap[cluster], $"Cluster {cluster} should have correct timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_EmptyDataRun_Skipped()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
var timestamp = 133303440000000000L;
|
||||
|
||||
// MFT record 0 describes the MFT itself - place at cluster 100
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(100, 10)],
|
||||
inUse: true);
|
||||
|
||||
// Record with no $DATA attribute (like a directory)
|
||||
var mftRecord1 = CreateMftRecord(
|
||||
timestamp,
|
||||
null, // No data runs
|
||||
inUse: true);
|
||||
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
return recordNumber switch
|
||||
{
|
||||
0 => mftRecord0,
|
||||
1 => mftRecord1,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
|
||||
// The record should be processed (no exception), but no additional clusters mapped
|
||||
// System metafiles should still be present
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.Count >= 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsMftWalker_JournalClusters_AlwaysAllocated()
|
||||
{
|
||||
var bootSectorData = CreateValidBootSector();
|
||||
var bootSector = new NtfsBootSector(bootSectorData);
|
||||
|
||||
// Create bitmap with all clusters allocated
|
||||
var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
|
||||
var bitmap = new NtfsBitmap(bootSector, bitmapData);
|
||||
|
||||
// MFT record 0 describes the MFT itself - place at cluster 100
|
||||
var mftRecord0 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(100, 20)],
|
||||
inUse: true);
|
||||
|
||||
// MFT record 2 is $LogFile - the NTFS transaction journal
|
||||
// Its clusters must always be backed up as they change on every write
|
||||
var mftRecord2 = CreateMftRecord(
|
||||
DateTime.UtcNow.ToFileTimeUtc(),
|
||||
[(50, 10)], // $LogFile at clusters 50-59
|
||||
inUse: true);
|
||||
|
||||
byte[]? GetMftRecord(long recordNumber)
|
||||
{
|
||||
return recordNumber switch
|
||||
{
|
||||
0 => mftRecord0,
|
||||
2 => mftRecord2,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var before = DateTime.UtcNow.AddSeconds(-1);
|
||||
var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
|
||||
await walker.WalkAsync();
|
||||
var after = DateTime.UtcNow.AddSeconds(1);
|
||||
|
||||
// $LogFile clusters (50-59) should be mapped with a current timestamp
|
||||
for (var cluster = 50; cluster < 60; cluster++)
|
||||
{
|
||||
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"$LogFile cluster {cluster} should be in map");
|
||||
var timestamp = walker.ClusterToTimestampMap[cluster];
|
||||
Assert.GreaterOrEqual(timestamp, before, $"$LogFile cluster {cluster} timestamp should be >= before");
|
||||
Assert.LessOrEqual(timestamp, after, $"$LogFile cluster {cluster} timestamp should be <= after");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Duplicati.UnitTest.DiskImage.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="NtfsFilesystem.NtfsZeroStream"/> class.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("DiskImageUnit")]
|
||||
public class NtfsZeroStreamTests
|
||||
{
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_Read_ReturnsZeros()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(1024, bytesRead, "Should read requested number of bytes");
|
||||
|
||||
// Verify all bytes are zero
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test_NtfsZeroStream_ReadAsync_ReturnsZeros()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(1024, bytesRead, "Should read requested number of bytes");
|
||||
|
||||
// Verify all bytes are zero
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_Length_MatchesBlockSize()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
Assert.AreEqual(blockSize, stream.Length, "Stream length should match block size");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_Seek_Works()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
// Seek to beginning
|
||||
var position = stream.Seek(0, SeekOrigin.Begin);
|
||||
Assert.AreEqual(0, position, "Should be at position 0");
|
||||
|
||||
// Seek to middle
|
||||
position = stream.Seek(blockSize / 2, SeekOrigin.Begin);
|
||||
Assert.AreEqual(blockSize / 2, position, "Should be at middle position");
|
||||
|
||||
// Seek relative to current
|
||||
position = stream.Seek(1024, SeekOrigin.Current);
|
||||
Assert.AreEqual(blockSize / 2 + 1024, position, "Should be at relative position");
|
||||
|
||||
// Seek from end
|
||||
position = stream.Seek(-1024, SeekOrigin.End);
|
||||
Assert.AreEqual(blockSize - 1024, position, "Should be at position from end");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_Write_ThrowsNotSupported()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
Assert.Throws<NotSupportedException>(() => stream.Write(buffer, 0, buffer.Length));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_ReadPartial_ReturnsCorrectCount()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
// Seek to near the end
|
||||
stream.Seek(blockSize - 100, SeekOrigin.Begin);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(100, bytesRead, "Should only read remaining bytes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_ReadPastEnd_ReturnsZero()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
// Seek to end
|
||||
stream.Seek(0, SeekOrigin.End);
|
||||
|
||||
var buffer = new byte[1024];
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
Assert.AreEqual(0, bytesRead, "Should return 0 when reading past end");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_CanRead_CanWrite_CanSeek()
|
||||
{
|
||||
const int blockSize = 1024 * 1024; // 1MB
|
||||
using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
|
||||
|
||||
Assert.IsTrue(stream.CanRead, "CanRead should be true");
|
||||
Assert.IsFalse(stream.CanWrite, "CanWrite should be false");
|
||||
Assert.IsTrue(stream.CanSeek, "CanSeek should be true");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_NtfsZeroStream_InvalidBlockSize_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new NtfsFilesystem.NtfsZeroStream(0));
|
||||
Assert.Throws<ArgumentException>(() => new NtfsFilesystem.NtfsZeroStream(-1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for NTFS filesystems, storing cluster and block size information.
|
||||
/// </summary>
|
||||
public sealed record NtfsFilesystemMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the block size used for reading/writing data.
|
||||
/// </summary>
|
||||
public int BlockSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of a cluster in bytes.
|
||||
/// </summary>
|
||||
public int ClusterSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of clusters in the volume.
|
||||
/// </summary>
|
||||
public long TotalClusters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of allocated (in-use) clusters.
|
||||
/// </summary>
|
||||
public long AllocatedClusters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of free clusters.
|
||||
/// </summary>
|
||||
public long FreeClusters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of an MFT record in bytes.
|
||||
/// </summary>
|
||||
public int MftRecordSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the filesystem has a journal ($LogFile).
|
||||
/// Always true for NTFS.
|
||||
/// </summary>
|
||||
public bool HasJournal => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file (block) in an NTFS filesystem.
|
||||
/// </summary>
|
||||
public class NtfsFile : IFile
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public long? Address { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDirectory => false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last modification timestamp of the block.
|
||||
/// This is the maximum timestamp of any file whose clusters overlap this block.
|
||||
/// </summary>
|
||||
public DateTime? LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the block contains any allocated clusters.
|
||||
/// </summary>
|
||||
public bool IsAllocated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an NTFS filesystem.
|
||||
/// Provides filesystem-aware block-level access with cluster allocation tracking.
|
||||
/// </summary>
|
||||
internal class NtfsFilesystem : IFilesystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Cache of zero buffers keyed by block size.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<int, byte[]> s_zeroBuffers = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public FileSystemType Type => FileSystemType.NTFS;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPartition Partition { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the block size used for reading/writing data.
|
||||
/// </summary>
|
||||
private readonly int m_blockSize;
|
||||
|
||||
/// <summary>
|
||||
/// The parsed NTFS boot sector.
|
||||
/// </summary>
|
||||
private readonly NtfsBootSector m_bootSector;
|
||||
|
||||
/// <summary>
|
||||
/// The bitmap reader for cluster allocation.
|
||||
/// </summary>
|
||||
private readonly NtfsBitmap m_bitmap;
|
||||
|
||||
/// <summary>
|
||||
/// The MFT walker that built the cluster-to-timestamp map.
|
||||
/// </summary>
|
||||
private readonly NtfsMftWalker m_mftWalker;
|
||||
|
||||
/// <summary>
|
||||
/// Maps block index to block metadata (LastModified, IsAllocated).
|
||||
/// </summary>
|
||||
private readonly BlockMetadata[] m_blockMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// The total number of blocks in the partition.
|
||||
/// </summary>
|
||||
private readonly long m_blockCount;
|
||||
|
||||
/// <summary>
|
||||
/// Flag to indicate whether the object has been disposed.
|
||||
/// </summary>
|
||||
private bool m_disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a single block.
|
||||
/// </summary>
|
||||
private readonly struct BlockMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The last modification timestamp for this block.
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this block contains any allocated clusters.
|
||||
/// </summary>
|
||||
public bool IsAllocated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsFilesystem"/> class.
|
||||
/// </summary>
|
||||
/// <param name="partition">The parent partition.</param>
|
||||
/// <param name="blockSize">The block size for reading/writing (default is 1MB).</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if partition is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown if block size is invalid.</exception>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the metadata array for all blocks.
|
||||
/// </summary>
|
||||
/// <returns>An array of BlockMetadata for each block.</returns>
|
||||
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 (var blockIndex = 0L; blockIndex < m_blockCount; blockIndex++)
|
||||
{
|
||||
var blockStart = blockIndex * m_blockSize;
|
||||
var 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
|
||||
var maxTimestamp = DateTime.UnixEpoch;
|
||||
var hasAllocatedClusters = false;
|
||||
|
||||
// Calculate which clusters are in this block
|
||||
var firstClusterInBlock = blockStart / clusterSize;
|
||||
var lastClusterInBlock = (blockEnd - 1) / clusterSize;
|
||||
|
||||
// Clamp to valid cluster range
|
||||
firstClusterInBlock = Math.Max(0, firstClusterInBlock);
|
||||
lastClusterInBlock = Math.Min(lastClusterInBlock, totalClusters - 1);
|
||||
|
||||
for (var 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to detect whether a given partition has an NTFS file system.
|
||||
/// </summary>
|
||||
/// <param name="disk">The raw disk to check for the filesystem.</param>
|
||||
/// <param name="offset">The offset on the disk where the partition starts.</param>
|
||||
/// <param name="size">The size of the partition.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to true if a valid NTFS filesystem is detected, otherwise false.</returns>
|
||||
public static async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (m_disposed)
|
||||
return;
|
||||
|
||||
m_disposed = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<object?> 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<object?>(metadata);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<IFile> ListFilesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
for (long i = 0; i < m_blockCount; i++)
|
||||
{
|
||||
var address = i * m_blockSize;
|
||||
var 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<IFile> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> 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));
|
||||
|
||||
var address = ntfsFile.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
var 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var address = ParsePathToAddress(path);
|
||||
var size = Math.Min((long)m_blockSize, Partition.Size - address);
|
||||
|
||||
return await OpenReadStreamAsync(new NtfsFile { Address = address, Size = size, IsAllocated = true }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> 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));
|
||||
|
||||
var address = ntfsFile.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
var 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenWriteStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var address = ParsePathToAddress(path);
|
||||
var size = Math.Min(m_blockSize, Partition.Size - address);
|
||||
|
||||
return await OpenWriteStreamAsync(new NtfsFile { Address = address, Size = size, IsAllocated = true }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> 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));
|
||||
|
||||
var address = ntfsFile.Address ?? throw new ArgumentException("File address is required.", nameof(file));
|
||||
var 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
|
||||
var 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadWriteStreamAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var address = ParsePathToAddress(path);
|
||||
var size = Math.Min(m_blockSize, Partition.Size - address);
|
||||
|
||||
return await OpenReadWriteStreamAsync(new NtfsFile { Address = address, Size = size, IsAllocated = true }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> GetFileLengthAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var address = ParsePathToAddress(path);
|
||||
var size = Math.Min((long)m_blockSize, Partition.Size - address);
|
||||
|
||||
return Task.FromResult(size);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a file path to extract the corresponding block address.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path to parse.</param>
|
||||
/// <returns>The block address corresponding to the file path.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if the path format is invalid.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the specified range is valid for the partition.
|
||||
/// </summary>
|
||||
/// <param name="start">The starting address of the range.</param>
|
||||
/// <param name="size">The size of the range.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown if the start or size is negative or exceeds the partition size.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown if the start or size is not a multiple of the sector size.</exception>
|
||||
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).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private class NtfsStream : Stream
|
||||
{
|
||||
/// <summary>
|
||||
/// The raw disk to read from.
|
||||
/// </summary>
|
||||
private readonly IRawDisk _disk;
|
||||
|
||||
/// <summary>
|
||||
/// The starting address on the disk for this stream.
|
||||
/// </summary>
|
||||
private readonly long _address;
|
||||
|
||||
/// <summary>
|
||||
/// The size of the stream in bytes.
|
||||
/// </summary>
|
||||
private readonly long _size;
|
||||
|
||||
/// <summary>
|
||||
/// The buffer used to store data before writing to disk.
|
||||
/// </summary>
|
||||
private readonly byte[] _buffer;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the buffer was rented from the ArrayPool.
|
||||
/// </summary>
|
||||
private readonly bool _bufferRented;
|
||||
|
||||
/// <summary>
|
||||
/// The current position within the buffer.
|
||||
/// </summary>
|
||||
private long _position;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the data in the buffer is valid (has been read from disk).
|
||||
/// </summary>
|
||||
private bool _validData;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream has been disposed.
|
||||
/// </summary>
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream supports reading.
|
||||
/// </summary>
|
||||
private readonly bool _readEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream supports writing.
|
||||
/// </summary>
|
||||
private readonly bool _writeEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the buffer has been modified (dirty).
|
||||
/// </summary>
|
||||
private bool _dirty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="disk">The raw disk to read from/write to.</param>
|
||||
/// <param name="address">The starting address on the disk for this stream.</param>
|
||||
/// <param name="size">The size of the stream in bytes.</param>
|
||||
/// <param name="readEnabled">Whether reading is enabled.</param>
|
||||
/// <param name="writeEnabled">Whether writing is enabled.</param>
|
||||
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<byte>.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;
|
||||
}
|
||||
|
||||
var 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<int> 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;
|
||||
}
|
||||
|
||||
var 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<byte>.Shared.Return(_buffer);
|
||||
}
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously releases the unmanaged resources used by the <see cref="NtfsStream"/>.
|
||||
/// </summary>
|
||||
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<byte>.Shared.Return(_buffer);
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stream that returns zeros without reading from disk.
|
||||
/// Used for unallocated blocks to optimize backup performance.
|
||||
/// </summary>
|
||||
internal class NtfsZeroStream : Stream
|
||||
{
|
||||
/// <summary>
|
||||
/// The size of the stream in bytes.
|
||||
/// </summary>
|
||||
private readonly long _size;
|
||||
|
||||
/// <summary>
|
||||
/// The shared zero buffer for this block size.
|
||||
/// </summary>
|
||||
private readonly byte[] _zeroBuffer;
|
||||
|
||||
/// <summary>
|
||||
/// The current position within the stream.
|
||||
/// </summary>
|
||||
private long _position;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the stream has been disposed.
|
||||
/// </summary>
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsZeroStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="size">The size of the stream in bytes.</param>
|
||||
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");
|
||||
|
||||
var 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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and provides access to the NTFS $Bitmap file, which tracks cluster allocation.
|
||||
/// Each bit in the bitmap represents one cluster: 1 = allocated, 0 = free.
|
||||
/// </summary>
|
||||
internal class NtfsBitmap
|
||||
{
|
||||
/// <summary>
|
||||
/// Magic number "FILE" at the start of an MFT record (little-endian: 0x454C4946).
|
||||
/// </summary>
|
||||
private const uint MFT_RECORD_MAGIC = 0x454C4946;
|
||||
|
||||
/// <summary>
|
||||
/// MFT record flag indicating the record is in use.
|
||||
/// </summary>
|
||||
private const ushort MFT_RECORD_IN_USE = 0x0001;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $STANDARD_INFORMATION.
|
||||
/// </summary>
|
||||
private const uint ATTR_STANDARD_INFORMATION = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $ATTRIBUTE_LIST.
|
||||
/// </summary>
|
||||
private const uint ATTR_ATTRIBUTE_LIST = 0x20;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $FILE_NAME.
|
||||
/// </summary>
|
||||
private const uint ATTR_FILE_NAME = 0x30;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $DATA.
|
||||
/// </summary>
|
||||
private const uint ATTR_DATA = 0x80;
|
||||
|
||||
/// <summary>
|
||||
/// Flag indicating a non-resident attribute.
|
||||
/// </summary>
|
||||
private const byte ATTR_NON_RESIDENT_FLAG = 0x01;
|
||||
|
||||
/// <summary>
|
||||
/// The boot sector containing NTFS geometry information.
|
||||
/// </summary>
|
||||
private readonly NtfsBootSector m_bootSector;
|
||||
|
||||
/// <summary>
|
||||
/// The raw bitmap data.
|
||||
/// </summary>
|
||||
private readonly byte[] m_bitmapData;
|
||||
|
||||
/// <summary>
|
||||
/// BitArray for fast cluster allocation lookup.
|
||||
/// </summary>
|
||||
private readonly BitArray m_allocationBitmap;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of clusters in the volume.
|
||||
/// </summary>
|
||||
private readonly long m_totalClusters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsBitmap"/> class by reading the $Bitmap from disk.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition containing the NTFS filesystem.</param>
|
||||
/// <param name="bootSector">The parsed boot sector containing NTFS geometry.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if partition or bootSector is null.</exception>
|
||||
/// <exception cref="InvalidDataException">Thrown if the $Bitmap record cannot be parsed.</exception>
|
||||
public NtfsBitmap(IPartition partition, NtfsBootSector bootSector, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(partition);
|
||||
m_bootSector = bootSector;
|
||||
m_totalClusters = bootSector.TotalClusters;
|
||||
|
||||
// Calculate the size of the bitmap in bytes (1 bit per cluster, rounded up)
|
||||
var bitmapSizeInBytes = (int)((m_totalClusters + 7) / 8);
|
||||
|
||||
// Read the $Bitmap MFT record (record 6)
|
||||
var bitmapRecordData = ReadMftRecord(partition, 6, cancellationToken);
|
||||
|
||||
// Parse the $DATA attribute to get the bitmap data
|
||||
m_bitmapData = ExtractBitmapData(partition, bitmapRecordData, bitmapSizeInBytes, cancellationToken);
|
||||
|
||||
// Build the allocation bitmap
|
||||
m_allocationBitmap = BuildAllocationBitmap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsBitmap"/> class from raw bitmap data.
|
||||
/// This constructor is primarily used for testing.
|
||||
/// </summary>
|
||||
/// <param name="bootSector">The parsed boot sector containing NTFS geometry.</param>
|
||||
/// <param name="bitmapData">The raw bitmap data.</param>
|
||||
public NtfsBitmap(NtfsBootSector bootSector, byte[] bitmapData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmapData);
|
||||
m_bootSector = bootSector;
|
||||
m_bitmapData = bitmapData;
|
||||
m_totalClusters = bootSector.TotalClusters;
|
||||
m_allocationBitmap = BuildAllocationBitmap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of clusters in the volume.
|
||||
/// </summary>
|
||||
public long TotalClusters => m_totalClusters;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of allocated (in-use) clusters.
|
||||
/// </summary>
|
||||
public long AllocatedClusters
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
for (var i = 0; i < m_allocationBitmap.Length; i++)
|
||||
if (m_allocationBitmap[i])
|
||||
count++;
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of free clusters.
|
||||
/// </summary>
|
||||
public long FreeClusters => m_totalClusters - AllocatedClusters;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a cluster is allocated (in use).
|
||||
/// </summary>
|
||||
/// <param name="clusterNumber">The cluster number to check.</param>
|
||||
/// <returns>True if the cluster is allocated; otherwise, false.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown if cluster number is out of range.</exception>
|
||||
public bool IsClusterAllocated(long clusterNumber)
|
||||
{
|
||||
if (clusterNumber < 0 || clusterNumber >= m_totalClusters)
|
||||
throw new ArgumentOutOfRangeException(nameof(clusterNumber), $"Cluster number {clusterNumber} is out of range. Valid range is 0 to {m_totalClusters - 1}.");
|
||||
|
||||
return m_allocationBitmap[(int)clusterNumber];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an MFT record from disk.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition to read from.</param>
|
||||
/// <param name="recordNumber">The MFT record number to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw MFT record data with fixup array applied.</returns>
|
||||
private byte[] ReadMftRecord(IPartition partition, long recordNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
var recordSize = m_bootSector.MftRecordSize;
|
||||
var recordOffset = m_bootSector.MftByteOffset + (recordNumber * recordSize);
|
||||
|
||||
var recordData = new byte[recordSize];
|
||||
|
||||
using var stream = partition.OpenReadAsync(cancellationToken).Result;
|
||||
stream.Seek(recordOffset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(recordData);
|
||||
|
||||
// Apply the fixup array to restore the original sector data
|
||||
ApplyFixupArray(recordData);
|
||||
|
||||
return recordData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the bitmap data from the $Bitmap MFT record.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition to read from.</param>
|
||||
/// <param name="recordData">The MFT record data.</param>
|
||||
/// <param name="expectedSize">The expected size of the bitmap data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bitmap data.</returns>
|
||||
private byte[] ExtractBitmapData(IPartition partition, byte[] recordData, int expectedSize, CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate the FILE magic
|
||||
var magic = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(0, 4));
|
||||
if (magic != MFT_RECORD_MAGIC)
|
||||
throw new InvalidDataException($"Invalid MFT record magic: expected 0x{MFT_RECORD_MAGIC:X8}, got 0x{magic:X8}.");
|
||||
|
||||
// Get the offset to the first attribute
|
||||
var firstAttributeOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(0x14, 2));
|
||||
|
||||
// Find the $DATA attribute (type 0x80)
|
||||
var attributeOffset = (int)firstAttributeOffset;
|
||||
while (attributeOffset < recordData.Length)
|
||||
{
|
||||
var attrType = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset, 4));
|
||||
|
||||
// End of attributes marker
|
||||
if (attrType == 0xFFFFFFFF)
|
||||
break;
|
||||
|
||||
// Found $DATA attribute
|
||||
if (attrType == ATTR_DATA)
|
||||
{
|
||||
var isNonResident = (recordData[attributeOffset + 8] & ATTR_NON_RESIDENT_FLAG) != 0;
|
||||
|
||||
if (isNonResident)
|
||||
return ReadNonResidentAttributeData(partition, recordData, attributeOffset, expectedSize, cancellationToken);
|
||||
else
|
||||
return ReadResidentAttributeData(recordData, attributeOffset);
|
||||
}
|
||||
|
||||
// Move to the next attribute
|
||||
var attrLength = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset + 4, 4));
|
||||
if (attrLength == 0)
|
||||
break;
|
||||
|
||||
attributeOffset += (int)attrLength;
|
||||
}
|
||||
|
||||
throw new InvalidDataException("$DATA attribute not found in $Bitmap MFT record.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads data from a resident attribute.
|
||||
/// </summary>
|
||||
/// <param name="recordData">The MFT record data.</param>
|
||||
/// <param name="attributeOffset">The offset to the attribute.</param>
|
||||
/// <returns>The attribute data.</returns>
|
||||
private static byte[] ReadResidentAttributeData(byte[] recordData, int attributeOffset)
|
||||
{
|
||||
// Resident attribute header:
|
||||
// 0x00: Attribute type (4 bytes)
|
||||
// 0x04: Attribute length (4 bytes)
|
||||
// 0x08: Non-resident flag (1 byte)
|
||||
// 0x09: Name length (1 byte)
|
||||
// 0x0A: Name offset (2 bytes)
|
||||
// 0x0C: Flags (2 bytes)
|
||||
// 0x0E: Attribute ID (2 bytes)
|
||||
// 0x10: Data length (4 bytes)
|
||||
// 0x14: Data offset (2 bytes)
|
||||
// 0x16: Reserved (1 byte)
|
||||
// 0x17: Reserved (1 byte)
|
||||
// Data starts at offset specified by data offset field
|
||||
|
||||
var dataLength = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset + 0x10, 4));
|
||||
var dataOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(attributeOffset + 0x14, 2));
|
||||
|
||||
var data = new byte[dataLength];
|
||||
Buffer.BlockCopy(recordData, attributeOffset + dataOffset, data, 0, (int)dataLength);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads data from a non-resident attribute by following data runs.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition to read from.</param>
|
||||
/// <param name="recordData">The MFT record data.</param>
|
||||
/// <param name="attributeOffset">The offset to the attribute.</param>
|
||||
/// <param name="expectedSize">The expected size of the data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attribute data.</returns>
|
||||
private byte[] ReadNonResidentAttributeData(IPartition partition, byte[] recordData, int attributeOffset, int expectedSize, CancellationToken cancellationToken)
|
||||
{
|
||||
// Non-resident attribute header:
|
||||
// 0x00: Attribute type (4 bytes)
|
||||
// 0x04: Attribute length (4 bytes)
|
||||
// 0x08: Non-resident flag (1 byte)
|
||||
// 0x09: Name length (1 byte)
|
||||
// 0x0A: Name offset (2 bytes)
|
||||
// 0x0C: Flags (2 bytes)
|
||||
// 0x0E: Attribute ID (2 bytes)
|
||||
// 0x10: Starting VCN (8 bytes)
|
||||
// 0x18: Ending VCN (8 bytes)
|
||||
// 0x20: Run offset (2 bytes) - offset to data run list from start of attribute
|
||||
// 0x22: Compression unit size (2 bytes)
|
||||
// 0x24: Padding (4 bytes)
|
||||
// 0x28: Allocated size (8 bytes)
|
||||
// 0x30: Data size (8 bytes)
|
||||
// 0x38: Initialized size (8 bytes)
|
||||
// 0x40: Data runs start at offset specified by run offset field. If the attribute is named, then the name starts here. Otherwise, the data runs start here.
|
||||
|
||||
var runOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(attributeOffset + 0x20, 2));
|
||||
var dataSize = BinaryPrimitives.ReadUInt64LittleEndian(recordData.AsSpan(attributeOffset + 0x30, 8));
|
||||
|
||||
// Parse the data runs
|
||||
var dataRuns = ParseDataRuns(recordData, attributeOffset + runOffset);
|
||||
|
||||
// Read the data from the clusters specified by the data runs
|
||||
var result = new byte[Math.Min(expectedSize, (long)dataSize)];
|
||||
var resultOffset = 0;
|
||||
|
||||
using var stream = partition.OpenReadAsync(cancellationToken).Result;
|
||||
|
||||
foreach (var (startCluster, clusterCount) in dataRuns)
|
||||
{
|
||||
if (startCluster == 0)
|
||||
{
|
||||
// Sparse run (unallocated extent) - fill with zeros
|
||||
var sparseBytes = (int)Math.Min(clusterCount * m_bootSector.ClusterSize, result.Length - resultOffset);
|
||||
Array.Clear(result, resultOffset, sparseBytes);
|
||||
resultOffset += sparseBytes;
|
||||
}
|
||||
else
|
||||
{
|
||||
var bytesToRead = (int)Math.Min(clusterCount * m_bootSector.ClusterSize, result.Length - resultOffset);
|
||||
var byteOffset = startCluster * m_bootSector.ClusterSize;
|
||||
|
||||
stream.Seek(byteOffset, SeekOrigin.Begin);
|
||||
stream.ReadExactly(result, resultOffset, bytesToRead);
|
||||
resultOffset += bytesToRead;
|
||||
}
|
||||
|
||||
if (resultOffset >= result.Length)
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the data run list from an attribute.
|
||||
/// </summary>
|
||||
/// <param name="recordData">The MFT record data containing the data runs.</param>
|
||||
/// <param name="runsOffset">The offset to the start of the data run list.</param>
|
||||
/// <returns>A list of (startCluster, clusterCount) tuples.</returns>
|
||||
internal static List<(long startCluster, long clusterCount)> ParseDataRuns(byte[] recordData, int runsOffset)
|
||||
{
|
||||
var runs = new List<(long startCluster, long clusterCount)>();
|
||||
long currentCluster = 0;
|
||||
var offset = runsOffset;
|
||||
|
||||
while (offset < recordData.Length)
|
||||
{
|
||||
var header = recordData[offset++];
|
||||
|
||||
// End of data runs
|
||||
if (header == 0)
|
||||
break;
|
||||
|
||||
// Extract length and offset byte counts from header
|
||||
var lengthBytes = header & 0x0F;
|
||||
var offsetBytes = (header >> 4) & 0x0F;
|
||||
|
||||
if (lengthBytes == 0 || lengthBytes > 8 || offsetBytes > 8)
|
||||
throw new InvalidDataException($"Invalid data run header: 0x{header:X2}");
|
||||
|
||||
// Read the run length (number of clusters)
|
||||
long runLength = 0;
|
||||
for (var i = 0; i < lengthBytes; i++)
|
||||
runLength |= (long)recordData[offset++] << (i * 8);
|
||||
|
||||
// Read the run offset (signed, relative to previous run)
|
||||
long runOffsetValue = 0;
|
||||
if (offsetBytes > 0)
|
||||
{
|
||||
// Check if the offset is negative (sign bit is set in the last byte)
|
||||
var isNegative = (recordData[offset + offsetBytes - 1] & 0x80) != 0;
|
||||
|
||||
for (var i = 0; i < offsetBytes; i++)
|
||||
runOffsetValue |= (long)recordData[offset++] << (i * 8);
|
||||
|
||||
// Sign-extend if negative
|
||||
if (isNegative)
|
||||
runOffsetValue |= -1L << (offsetBytes * 8);
|
||||
}
|
||||
|
||||
// Calculate absolute cluster number
|
||||
currentCluster += runOffsetValue;
|
||||
|
||||
// A zero offset indicates a sparse run (unallocated extent)
|
||||
if (runOffsetValue == 0)
|
||||
runs.Add((0, runLength)); // Sparse run
|
||||
else
|
||||
runs.Add((currentCluster, runLength));
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the data run list from a ReadOnlySpan.
|
||||
/// </summary>
|
||||
/// <param name="data">The data containing the data runs.</param>
|
||||
/// <param name="runsOffset">The offset to the start of the data run list.</param>
|
||||
/// <returns>A list of (startCluster, clusterCount) tuples.</returns>
|
||||
internal static List<(long startCluster, long clusterCount)> ParseDataRuns(ReadOnlySpan<byte> data, int runsOffset)
|
||||
{
|
||||
var runs = new List<(long startCluster, long clusterCount)>();
|
||||
var currentCluster = 0L;
|
||||
var offset = runsOffset;
|
||||
|
||||
while (offset < data.Length)
|
||||
{
|
||||
var header = data[offset++];
|
||||
|
||||
// End of data runs
|
||||
if (header == 0)
|
||||
break;
|
||||
|
||||
// Extract length and offset byte counts from header
|
||||
var lengthBytes = header & 0x0F;
|
||||
var offsetBytes = (header >> 4) & 0x0F;
|
||||
|
||||
if (lengthBytes == 0 || lengthBytes > 8 || offsetBytes > 8)
|
||||
throw new InvalidDataException($"Invalid data run header: 0x{header:X2}");
|
||||
|
||||
// Read the run length (number of clusters)
|
||||
var runLength = 0L;
|
||||
for (var i = 0; i < lengthBytes; i++)
|
||||
runLength |= (long)data[offset++] << (i * 8);
|
||||
|
||||
// Read the run offset (signed, relative to previous run)
|
||||
var runOffsetValue = 0L;
|
||||
if (offsetBytes > 0)
|
||||
{
|
||||
// Check if the offset is negative (sign bit is set in the last byte)
|
||||
var isNegative = (data[offset + offsetBytes - 1] & 0x80) != 0;
|
||||
|
||||
for (var i = 0; i < offsetBytes; i++)
|
||||
runOffsetValue |= (long)data[offset++] << (i * 8);
|
||||
|
||||
// Sign-extend if negative
|
||||
if (isNegative)
|
||||
runOffsetValue |= -1L << (offsetBytes * 8);
|
||||
}
|
||||
|
||||
// Calculate absolute cluster number
|
||||
currentCluster += runOffsetValue;
|
||||
|
||||
// A zero offset indicates a sparse run (unallocated extent)
|
||||
if (runOffsetValue == 0)
|
||||
runs.Add((0, runLength)); // Sparse run
|
||||
else
|
||||
runs.Add((currentCluster, runLength));
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the fixup array to restore the original sector data in an MFT record.
|
||||
/// Every NTFS multi-sector structure uses a fixup array for integrity checking.
|
||||
/// The last two bytes of each 512-byte sector are replaced with a sequence number during write;
|
||||
/// the original values are stored in the fixup array.
|
||||
/// </summary>
|
||||
/// <param name="recordData">The MFT record data to fix up.</param>
|
||||
/// <exception cref="InvalidDataException">Thrown if fixup validation fails.</exception>
|
||||
internal static void ApplyFixupArray(byte[] recordData)
|
||||
{
|
||||
// Fixup array is at offset 0x04 in the MFT record header:
|
||||
// 0x04: Fixup offset (2 bytes) - offset to fixup array
|
||||
// 0x06: Fixup count (2 bytes) - number of entries in fixup array (also number of sectors)
|
||||
|
||||
var fixupOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(0x04, 2));
|
||||
var fixupCount = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(0x06, 2));
|
||||
|
||||
if (fixupCount == 0)
|
||||
return; // No fixup needed
|
||||
|
||||
// The fixup array starts with the sequence number (2 bytes), followed by the original values
|
||||
var sequenceNumber = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(fixupOffset, 2));
|
||||
|
||||
// Apply fixup to each sector
|
||||
for (var i = 1; i < fixupCount; i++)
|
||||
{
|
||||
var sectorOffset = i * 512 - 2; // Last 2 bytes of each 512-byte sector
|
||||
|
||||
// Verify the sequence number matches
|
||||
var sectorSequence = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(sectorOffset, 2));
|
||||
if (sectorSequence != sequenceNumber)
|
||||
throw new InvalidDataException(
|
||||
$"Fixup sequence number mismatch at sector {i}: expected 0x{sequenceNumber:X4}, got 0x{sectorSequence:X4}.");
|
||||
|
||||
// Restore the original value from the fixup array
|
||||
var originalValue = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(fixupOffset + i * 2, 2));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(sectorOffset, 2), originalValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the fixup array to restore the original sector data in an MFT record (span version).
|
||||
/// </summary>
|
||||
/// <param name="recordData">The MFT record data to fix up.</param>
|
||||
/// <exception cref="InvalidDataException">Thrown if fixup validation fails.</exception>
|
||||
internal static void ApplyFixupArray(Span<byte> recordData)
|
||||
{
|
||||
var fixupOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.Slice(0x04, 2));
|
||||
var fixupCount = BinaryPrimitives.ReadUInt16LittleEndian(recordData.Slice(0x06, 2));
|
||||
|
||||
if (fixupCount == 0)
|
||||
return;
|
||||
|
||||
var sequenceNumber = BinaryPrimitives.ReadUInt16LittleEndian(recordData.Slice(fixupOffset, 2));
|
||||
|
||||
for (var i = 1; i < fixupCount; i++)
|
||||
{
|
||||
var sectorOffset = i * 512 - 2;
|
||||
|
||||
var sectorSequence = BinaryPrimitives.ReadUInt16LittleEndian(recordData.Slice(sectorOffset, 2));
|
||||
if (sectorSequence != sequenceNumber)
|
||||
throw new InvalidDataException(
|
||||
$"Fixup sequence number mismatch at sector {i}: expected 0x{sequenceNumber:X4}, got 0x{sectorSequence:X4}.");
|
||||
|
||||
var originalValue = BinaryPrimitives.ReadUInt16LittleEndian(recordData.Slice(fixupOffset + i * 2, 2));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(recordData.Slice(sectorOffset, 2), originalValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the cluster allocation bitmap from the bitmap data.
|
||||
/// </summary>
|
||||
/// <returns>A BitArray where each bit indicates whether the corresponding cluster is allocated.</returns>
|
||||
private BitArray BuildAllocationBitmap()
|
||||
{
|
||||
var bitmap = new BitArray((int)m_totalClusters);
|
||||
|
||||
for (var i = 0; i < m_totalClusters; i++)
|
||||
{
|
||||
var byteIndex = i / 8;
|
||||
var bitIndex = i % 8;
|
||||
|
||||
if (byteIndex < m_bitmapData.Length)
|
||||
// NTFS uses LSB-first ordering: bit 0 of byte 0 is cluster 0
|
||||
bitmap[i] = (m_bitmapData[byteIndex] & (1 << bitIndex)) != 0;
|
||||
else
|
||||
bitmap[i] = false; // Beyond bitmap size, treat as free
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed NTFS Boot Sector (BIOS Parameter Block and extended NTFS fields).
|
||||
/// This is a read-only record struct that extracts geometry information from the boot sector.
|
||||
/// </summary>
|
||||
public readonly record struct NtfsBootSector
|
||||
{
|
||||
/// <summary>
|
||||
/// The boot sector signature at offset 510.
|
||||
/// On disk the bytes are 0x55, 0xAA; read as little-endian uint16 this is 0xAA55.
|
||||
/// </summary>
|
||||
private const ushort BootSectorSignature = 0xAA55;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the boot sector signature.
|
||||
/// </summary>
|
||||
private const int SignatureOffset = 510;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the OEM ID field.
|
||||
/// </summary>
|
||||
private const int OemIdOffset = 0x03;
|
||||
|
||||
/// <summary>
|
||||
/// Length of the OEM ID field (8 bytes).
|
||||
/// </summary>
|
||||
private const int OemIdLength = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Expected OEM ID value for NTFS: "NTFS "
|
||||
/// </summary>
|
||||
private const string ExpectedOemId = "NTFS ";
|
||||
|
||||
/// <summary>
|
||||
/// Offset to BytesPerSector field.
|
||||
/// </summary>
|
||||
private const int BytesPerSectorOffset = 0x0B;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to SectorsPerCluster field.
|
||||
/// </summary>
|
||||
private const int SectorsPerClusterOffset = 0x0D;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to TotalSectors field (uint64).
|
||||
/// </summary>
|
||||
private const int TotalSectorsOffset = 0x28;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to MftClusterNumber field (uint64).
|
||||
/// </summary>
|
||||
private const int MftClusterNumberOffset = 0x30;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to MftMirrorClusterNumber field (uint64).
|
||||
/// </summary>
|
||||
private const int MftMirrorClusterNumberOffset = 0x38;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to ClustersPerMftRecord field (int8).
|
||||
/// </summary>
|
||||
private const int ClustersPerMftRecordOffset = 0x40;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to ClustersPerIndexBlock field (int8).
|
||||
/// </summary>
|
||||
private const int ClustersPerIndexBlockOffset = 0x44;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to VolumeSerialNumber field (uint64).
|
||||
/// </summary>
|
||||
private const int VolumeSerialNumberOffset = 0x48;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid bytes per sector (512).
|
||||
/// </summary>
|
||||
private const ushort MinBytesPerSector = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum valid bytes per sector (4096).
|
||||
/// </summary>
|
||||
private const ushort MaxBytesPerSector = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid sectors per cluster (1).
|
||||
/// </summary>
|
||||
private const byte MinSectorsPerCluster = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum valid sectors per cluster (128).
|
||||
/// </summary>
|
||||
private const byte MaxSectorsPerCluster = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum required buffer size (512 bytes for boot sector).
|
||||
/// </summary>
|
||||
private const int MinimumBufferSize = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of bytes per sector (typically 512, 1024, 2048, or 4096).
|
||||
/// </summary>
|
||||
public ushort BytesPerSector { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of sectors per cluster (power of 2: 1, 2, 4, 8, 16, 32, 64, 128).
|
||||
/// </summary>
|
||||
public byte SectorsPerCluster { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of sectors in the volume (uint64).
|
||||
/// </summary>
|
||||
public ulong TotalSectors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cluster number where the $MFT starts.
|
||||
/// </summary>
|
||||
public ulong MftClusterNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cluster number where the $MFTMirr (MFT mirror) starts.
|
||||
/// </summary>
|
||||
public ulong MftMirrorClusterNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the clusters per MFT record as encoded in the boot sector.
|
||||
/// If negative, record size is 2^abs(value) bytes.
|
||||
/// If positive, record size is value * ClusterSize bytes.
|
||||
/// </summary>
|
||||
public sbyte ClustersPerMftRecord { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the clusters per index block as encoded in the boot sector.
|
||||
/// Same encoding as ClustersPerMftRecord.
|
||||
/// </summary>
|
||||
public sbyte ClustersPerIndexBlock { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the volume serial number.
|
||||
/// </summary>
|
||||
public ulong VolumeSerialNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of a cluster in bytes.
|
||||
/// </summary>
|
||||
public int ClusterSize => BytesPerSector * SectorsPerCluster;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of an MFT record in bytes.
|
||||
/// If ClustersPerMftRecord is negative: 2^abs(ClustersPerMftRecord) bytes.
|
||||
/// If ClustersPerMftRecord is positive: ClustersPerMftRecord * ClusterSize bytes.
|
||||
/// </summary>
|
||||
public int MftRecordSize => ClustersPerMftRecord < 0
|
||||
? 1 << -ClustersPerMftRecord // 2^abs(value)
|
||||
: ClustersPerMftRecord * ClusterSize;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the byte offset from the partition start to the MFT.
|
||||
/// </summary>
|
||||
public long MftByteOffset => (long)MftClusterNumber * ClusterSize;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of clusters in the volume.
|
||||
/// </summary>
|
||||
public long TotalClusters => (long)(TotalSectors / SectorsPerCluster);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total size of the volume in bytes.
|
||||
/// </summary>
|
||||
public long TotalSize => (long)TotalSectors * BytesPerSector;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum valid total sectors for an NTFS volume.
|
||||
/// </summary>
|
||||
private const ulong MinTotalSectors = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsBootSector"/> struct by parsing the provided boot sector data.
|
||||
/// </summary>
|
||||
/// <param name="bootSectorData">The boot sector data (must be at least 512 bytes).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the buffer is too small or validation fails.</exception>
|
||||
public NtfsBootSector(ReadOnlySpan<byte> bootSectorData)
|
||||
{
|
||||
if (bootSectorData.Length < MinimumBufferSize)
|
||||
throw new ArgumentException($"Boot sector data must be at least {MinimumBufferSize} bytes.", nameof(bootSectorData));
|
||||
|
||||
// Validate boot sector signature (bytes 0x55, 0xAA at offset 510; 0xAA55 as little-endian uint16)
|
||||
var signature = BinaryPrimitives.ReadUInt16LittleEndian(bootSectorData.Slice(SignatureOffset, 2));
|
||||
if (signature != BootSectorSignature)
|
||||
throw new ArgumentException($"Invalid boot sector signature: expected 0x{BootSectorSignature:X4}, got 0x{signature:X4}.", nameof(bootSectorData));
|
||||
|
||||
// Validate OEM ID at offset 0x03 (should be "NTFS ")
|
||||
var oemId = Encoding.ASCII.GetString(bootSectorData.Slice(OemIdOffset, OemIdLength));
|
||||
if (oemId != ExpectedOemId)
|
||||
throw new ArgumentException($"Invalid OEM ID: expected '{ExpectedOemId}', got '{oemId}'.", nameof(bootSectorData));
|
||||
|
||||
// Parse fields from the boot sector
|
||||
BytesPerSector = BinaryPrimitives.ReadUInt16LittleEndian(bootSectorData.Slice(BytesPerSectorOffset, 2));
|
||||
SectorsPerCluster = bootSectorData[SectorsPerClusterOffset];
|
||||
TotalSectors = BinaryPrimitives.ReadUInt64LittleEndian(bootSectorData.Slice(TotalSectorsOffset, 8));
|
||||
MftClusterNumber = BinaryPrimitives.ReadUInt64LittleEndian(bootSectorData.Slice(MftClusterNumberOffset, 8));
|
||||
MftMirrorClusterNumber = BinaryPrimitives.ReadUInt64LittleEndian(bootSectorData.Slice(MftMirrorClusterNumberOffset, 8));
|
||||
ClustersPerMftRecord = (sbyte)bootSectorData[ClustersPerMftRecordOffset];
|
||||
ClustersPerIndexBlock = (sbyte)bootSectorData[ClustersPerIndexBlockOffset];
|
||||
VolumeSerialNumber = BinaryPrimitives.ReadUInt64LittleEndian(bootSectorData.Slice(VolumeSerialNumberOffset, 8));
|
||||
|
||||
// Validate BytesPerSector is a power of 2 (512, 1024, 2048, or 4096)
|
||||
if (!IsPowerOfTwo(BytesPerSector) || BytesPerSector < MinBytesPerSector || BytesPerSector > MaxBytesPerSector)
|
||||
throw new ArgumentException($"Invalid BytesPerSector: {BytesPerSector}. Must be a power of 2 between {MinBytesPerSector} and {MaxBytesPerSector}.", nameof(bootSectorData));
|
||||
|
||||
// Validate SectorsPerCluster is a power of 2 (1, 2, 4, 8, 16, 32, 64, or 128)
|
||||
if (!IsPowerOfTwo(SectorsPerCluster) || SectorsPerCluster < MinSectorsPerCluster || SectorsPerCluster > MaxSectorsPerCluster)
|
||||
throw new ArgumentException($"Invalid SectorsPerCluster: {SectorsPerCluster}. Must be a power of 2 between {MinSectorsPerCluster} and {MaxSectorsPerCluster}.", nameof(bootSectorData));
|
||||
|
||||
// Validate that BytesPerSector * SectorsPerCluster does not overflow
|
||||
// This is effectively checking that ClusterSize is valid
|
||||
try
|
||||
{
|
||||
_ = checked(BytesPerSector * SectorsPerCluster);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
throw new ArgumentException($"Cluster size overflow: BytesPerSector ({BytesPerSector}) * SectorsPerCluster ({SectorsPerCluster}) exceeds maximum value.", nameof(bootSectorData));
|
||||
}
|
||||
|
||||
// Validate TotalSectors > 0
|
||||
if (TotalSectors == 0)
|
||||
throw new ArgumentException($"Invalid TotalSectors: {TotalSectors}. Must be greater than 0.", nameof(bootSectorData));
|
||||
|
||||
if (TotalSectors < MinTotalSectors)
|
||||
throw new ArgumentException($"Invalid TotalSectors: {TotalSectors}. Must be at least {MinTotalSectors}.", nameof(bootSectorData));
|
||||
|
||||
// Validate MftClusterNumber is within volume bounds
|
||||
if (MftClusterNumber >= (ulong)TotalClusters)
|
||||
throw new ArgumentException($"Invalid MftClusterNumber: {MftClusterNumber}. Must be less than TotalClusters ({TotalClusters}).", nameof(bootSectorData));
|
||||
|
||||
// Validate MftRecordSize - if positive, ensure it doesn't overflow
|
||||
if (ClustersPerMftRecord > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = checked(ClustersPerMftRecord * ClusterSize);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
throw new ArgumentException($"MFT record size overflow: ClustersPerMftRecord ({ClustersPerMftRecord}) * ClusterSize ({ClusterSize}) exceeds maximum value.", nameof(bootSectorData));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate IndexBlockSize - if positive, ensure it doesn't overflow
|
||||
if (ClustersPerIndexBlock > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = checked(ClustersPerIndexBlock * ClusterSize);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
throw new ArgumentException($"Index block size overflow: ClustersPerIndexBlock ({ClustersPerIndexBlock}) * ClusterSize ({ClusterSize}) exceeds maximum value.", nameof(bootSectorData));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a value is a power of 2.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <returns>True if the value is a power of 2; otherwise, false.</returns>
|
||||
private static bool IsPowerOfTwo(ushort value) => value != 0 && (value & (value - 1)) == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a value is a power of 2.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <returns>True if the value is a power of 2; otherwise, false.</returns>
|
||||
private static bool IsPowerOfTwo(byte value) => value != 0 && (value & (value - 1)) == 0;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
|
||||
/// <summary>
|
||||
/// Walks the Master File Table (MFT) to build a cluster-to-timestamp map.
|
||||
/// This is analogous to Fat32DirectoryWalker for FAT32.
|
||||
/// </summary>
|
||||
internal class NtfsMftWalker
|
||||
{
|
||||
/// <summary>
|
||||
/// Magic number "FILE" at the start of an MFT record (little-endian: 0x454C4946).
|
||||
/// </summary>
|
||||
private const uint MFT_RECORD_MAGIC = 0x454C4946;
|
||||
|
||||
/// <summary>
|
||||
/// MFT record flag indicating the record is in use.
|
||||
/// </summary>
|
||||
private const ushort MFT_RECORD_IN_USE = 0x0001;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $STANDARD_INFORMATION.
|
||||
/// </summary>
|
||||
private const uint ATTR_STANDARD_INFORMATION = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $ATTRIBUTE_LIST.
|
||||
/// </summary>
|
||||
private const uint ATTR_ATTRIBUTE_LIST = 0x20;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute type code for $DATA.
|
||||
/// </summary>
|
||||
private const uint ATTR_DATA = 0x80;
|
||||
|
||||
/// <summary>
|
||||
/// Flag indicating a non-resident attribute.
|
||||
/// </summary>
|
||||
private const byte ATTR_NON_RESIDENT_FLAG = 0x01;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the flags field in the MFT record header.
|
||||
/// </summary>
|
||||
private const int FLAGS_OFFSET = 0x16;
|
||||
|
||||
/// <summary>
|
||||
/// Offset to the first attribute offset field in the MFT record header.
|
||||
/// </summary>
|
||||
private const int FIRST_ATTRIBUTE_OFFSET = 0x14;
|
||||
|
||||
/// <summary>
|
||||
/// The boot sector containing NTFS geometry information.
|
||||
/// </summary>
|
||||
private readonly NtfsBootSector m_bootSector;
|
||||
|
||||
/// <summary>
|
||||
/// The bitmap for cluster allocation information.
|
||||
/// </summary>
|
||||
private readonly NtfsBitmap m_bitmap;
|
||||
|
||||
/// <summary>
|
||||
/// The partition for reading MFT data (null when using testing constructor).
|
||||
/// </summary>
|
||||
private readonly IPartition? m_partition;
|
||||
|
||||
/// <summary>
|
||||
/// Function to get MFT record data for testing (null when using production constructor).
|
||||
/// </summary>
|
||||
private readonly Func<long, byte[]?>? m_mftRecordDataFunc;
|
||||
|
||||
/// <summary>
|
||||
/// Maps each allocated cluster to the modification time of the file that owns it.
|
||||
/// </summary>
|
||||
private readonly Dictionary<long, DateTime> m_clusterToTimestampMap;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cluster-to-timestamp map. Each allocated cluster is mapped to the modification time
|
||||
/// of the file or directory that owns it. Populated after calling <see cref="WalkAsync"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<long, DateTime> ClusterToTimestampMap => m_clusterToTimestampMap;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the MFT has been walked and the cluster map populated.
|
||||
/// </summary>
|
||||
public bool HasWalked { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The modification timestamp of the MFT file (record 0).
|
||||
/// </summary>
|
||||
private DateTime m_mftTimestamp;
|
||||
|
||||
/// <summary>
|
||||
/// The data runs of the MFT file.
|
||||
/// </summary>
|
||||
private List<(long startCluster, long clusterCount)> m_mftDataRuns;
|
||||
|
||||
/// <summary>
|
||||
/// Flag indicating if the MFT has been modified (any record has a newer timestamp).
|
||||
/// </summary>
|
||||
private bool m_mftModified;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsMftWalker"/> class.
|
||||
/// Call <see cref="WalkAsync"/> to perform the MFT walk and populate the cluster map.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition containing the NTFS filesystem.</param>
|
||||
/// <param name="bootSector">The parsed boot sector containing NTFS geometry.</param>
|
||||
/// <param name="bitmap">The bitmap for cluster allocation.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if any parameter is null.</exception>
|
||||
public NtfsMftWalker(IPartition partition, NtfsBootSector bootSector, NtfsBitmap bitmap)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(partition);
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
m_partition = partition;
|
||||
m_bootSector = bootSector;
|
||||
m_bitmap = bitmap;
|
||||
m_clusterToTimestampMap = [];
|
||||
m_mftRecordDataFunc = null;
|
||||
HasWalked = false;
|
||||
m_mftTimestamp = DateTime.MinValue;
|
||||
m_mftDataRuns = [];
|
||||
m_mftModified = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NtfsMftWalker"/> class for testing.
|
||||
/// This constructor allows direct specification of MFT record data without reading from a partition.
|
||||
/// Call <see cref="WalkAsync"/> to perform the MFT walk and populate the cluster map.
|
||||
/// </summary>
|
||||
/// <param name="bootSector">The parsed boot sector containing NTFS geometry.</param>
|
||||
/// <param name="bitmap">The bitmap for cluster allocation.</param>
|
||||
/// <param name="mftRecordData">A function that returns MFT record data for a given record number.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if any parameter is null.</exception>
|
||||
internal NtfsMftWalker(NtfsBootSector bootSector, NtfsBitmap bitmap, Func<long, byte[]?> mftRecordData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
ArgumentNullException.ThrowIfNull(mftRecordData);
|
||||
|
||||
m_bootSector = bootSector;
|
||||
m_bitmap = bitmap;
|
||||
m_clusterToTimestampMap = [];
|
||||
m_partition = null;
|
||||
m_mftRecordDataFunc = mftRecordData;
|
||||
HasWalked = false;
|
||||
m_mftTimestamp = DateTime.MinValue;
|
||||
m_mftDataRuns = [];
|
||||
m_mftModified = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the MFT and builds the cluster-to-timestamp map.
|
||||
/// This method populates the <see cref="ClusterToTimestampMap"/> property.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the walker has already been walked.</exception>
|
||||
public async Task WalkAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (HasWalked)
|
||||
throw new InvalidOperationException("The MFT walker has already been walked. Create a new instance to walk again.");
|
||||
|
||||
try
|
||||
{
|
||||
if (m_mftRecordDataFunc != null)
|
||||
{
|
||||
// Testing mode - use the provided function
|
||||
await WalkMftWithDataAsync(m_mftRecordDataFunc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (m_partition != null)
|
||||
{
|
||||
// Production mode - read from partition
|
||||
await WalkMftAsync(m_partition, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// If the MFT has been modified, mark all its clusters as changed
|
||||
if (m_mftModified)
|
||||
{
|
||||
var currentTimestamp = DateTime.UtcNow;
|
||||
foreach (var (startCluster, clusterCount) in m_mftDataRuns)
|
||||
{
|
||||
if (startCluster == 0)
|
||||
continue; // Skip sparse runs
|
||||
|
||||
for (var i = 0; i < clusterCount; i++)
|
||||
{
|
||||
var cluster = startCluster + i;
|
||||
m_clusterToTimestampMap[cluster] = currentTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark system metafiles (records 0-23) as allocated with current timestamp
|
||||
MarkSystemMetafilesWithCurrentTimestamp();
|
||||
|
||||
HasWalked = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Clear partial results on failure
|
||||
m_clusterToTimestampMap.Clear();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Windows FILETIME (100ns ticks since 1601-01-01) to a DateTime.
|
||||
/// A FILETIME of 0 maps to DateTime.UnixEpoch as a sentinel value.
|
||||
/// </summary>
|
||||
/// <param name="filetime">The FILETIME value to convert.</param>
|
||||
/// <returns>The corresponding DateTime in UTC.</returns>
|
||||
internal static DateTime ParseFileTime(long filetime)
|
||||
{
|
||||
if (filetime == 0)
|
||||
return DateTime.UnixEpoch;
|
||||
|
||||
try
|
||||
{
|
||||
return DateTime.FromFileTimeUtc(filetime);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
// If the FILETIME is out of range, return UnixEpoch
|
||||
return DateTime.UnixEpoch;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the MFT from the partition asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition to read from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private async Task WalkMftAsync(IPartition partition, CancellationToken cancellationToken)
|
||||
{
|
||||
// First, read MFT record 0 to get the data runs for the entire MFT
|
||||
var mftRecordData = await ReadMftRecordAsync(partition, 0, cancellationToken).ConfigureAwait(false);
|
||||
var mftDataRuns = GetMftDataRuns(mftRecordData);
|
||||
|
||||
// Calculate total number of MFT records from data runs
|
||||
var totalMftRecords = CalculateTotalMftRecords(mftDataRuns);
|
||||
|
||||
// Read all MFT records and build the cluster map
|
||||
for (var recordNumber = 0L; recordNumber < totalMftRecords; recordNumber++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ProcessMftRecordAsync(recordNumber, partition, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the MFT using provided record data asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="mftRecordData">Function to get MFT record data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private Task WalkMftWithDataAsync(Func<long, byte[]?> mftRecordData, CancellationToken cancellationToken)
|
||||
{
|
||||
// First, read MFT record 0 to get the data runs for the entire MFT
|
||||
var record0Data = mftRecordData(0);
|
||||
if (record0Data == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var mftDataRuns = GetMftDataRuns(record0Data);
|
||||
|
||||
// Calculate total number of MFT records from data runs
|
||||
var totalMftRecords = CalculateTotalMftRecords(mftDataRuns);
|
||||
|
||||
// Read all MFT records and build the cluster map
|
||||
for (var recordNumber = 0L; recordNumber < totalMftRecords; recordNumber++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var recordData = mftRecordData(recordNumber);
|
||||
if (recordData != null)
|
||||
ProcessMftRecordData(recordNumber, recordData);
|
||||
}
|
||||
|
||||
// If the MFT has been modified, mark all its clusters as changed
|
||||
if (m_mftModified)
|
||||
{
|
||||
var currentTimestamp = DateTime.UtcNow;
|
||||
foreach (var (startCluster, clusterCount) in m_mftDataRuns)
|
||||
{
|
||||
if (startCluster == 0)
|
||||
continue; // Skip sparse runs
|
||||
|
||||
for (var i = 0; i < clusterCount; i++)
|
||||
{
|
||||
var cluster = startCluster + i;
|
||||
m_clusterToTimestampMap[cluster] = currentTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an MFT record from disk asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="partition">The partition to read from.</param>
|
||||
/// <param name="recordNumber">The MFT record number to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw MFT record data with fixup array applied.</returns>
|
||||
private async Task<byte[]> ReadMftRecordAsync(IPartition partition, long recordNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
var recordSize = m_bootSector.MftRecordSize;
|
||||
var recordOffset = m_bootSector.MftByteOffset + (recordNumber * recordSize);
|
||||
|
||||
var recordData = new byte[recordSize];
|
||||
|
||||
using var stream = await partition.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
stream.Seek(recordOffset, SeekOrigin.Begin);
|
||||
await stream.ReadExactlyAsync(recordData, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Apply the fixup array to restore the original sector data
|
||||
NtfsBitmap.ApplyFixupArray(recordData);
|
||||
|
||||
return recordData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an MFT record from the partition asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="recordNumber">The MFT record number.</param>
|
||||
/// <param name="partition">The partition to read from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private async Task ProcessMftRecordAsync(long recordNumber, IPartition partition, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var recordData = await ReadMftRecordAsync(partition, recordNumber, cancellationToken).ConfigureAwait(false);
|
||||
ProcessMftRecordData(recordNumber, recordData);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
// Skip records that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes MFT record data to extract timestamps and cluster mappings.
|
||||
/// </summary>
|
||||
/// <param name="recordNumber">The MFT record number.</param>
|
||||
/// <param name="recordData">The MFT record data.</param>
|
||||
private void ProcessMftRecordData(long recordNumber, byte[] recordData)
|
||||
{
|
||||
// Validate the FILE magic
|
||||
var magic = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(0, 4));
|
||||
if (magic != MFT_RECORD_MAGIC)
|
||||
return; // Invalid record, skip
|
||||
|
||||
// Check if the record is in use
|
||||
var flags = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(FLAGS_OFFSET, 2));
|
||||
if ((flags & MFT_RECORD_IN_USE) == 0)
|
||||
return; // Deleted record, skip
|
||||
|
||||
// Get the modification timestamp from $STANDARD_INFORMATION
|
||||
var modificationTime = GetModificationTimestamp(recordData);
|
||||
|
||||
// Get data runs from $DATA attribute
|
||||
var dataRuns = GetDataRunsFromRecord(recordData);
|
||||
|
||||
if (recordNumber == 0)
|
||||
{
|
||||
// For the MFT file itself, store its timestamp and data runs
|
||||
m_mftTimestamp = modificationTime;
|
||||
m_mftDataRuns = dataRuns;
|
||||
}
|
||||
else
|
||||
{
|
||||
// For other records, check if they are newer than the MFT's timestamp
|
||||
if (modificationTime > m_mftTimestamp)
|
||||
{
|
||||
m_mftModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Map each cluster to the modification timestamp
|
||||
foreach (var (startCluster, clusterCount) in dataRuns)
|
||||
{
|
||||
if (startCluster == 0)
|
||||
continue; // Skip sparse runs
|
||||
|
||||
for (var i = 0; i < clusterCount; i++)
|
||||
{
|
||||
var cluster = startCluster + i;
|
||||
|
||||
// For overlapping clusters, use the most recent timestamp
|
||||
if (!m_clusterToTimestampMap.TryGetValue(cluster, out var existingTimestamp) ||
|
||||
modificationTime > existingTimestamp)
|
||||
{
|
||||
m_clusterToTimestampMap[cluster] = modificationTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the data runs from the $DATA attribute of the MFT record (record 0) to determine the full MFT extent.
|
||||
/// </summary>
|
||||
/// <param name="mftRecordData">The MFT record 0 data.</param>
|
||||
/// <returns>List of data runs describing the full MFT extent.</returns>
|
||||
private List<(long startCluster, long clusterCount)> GetMftDataRuns(byte[] mftRecordData)
|
||||
=> GetDataRunsFromRecord(mftRecordData);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the total number of MFT records from the data runs.
|
||||
/// </summary>
|
||||
/// <param name="dataRuns">The data runs describing the MFT extent.</param>
|
||||
/// <returns>The total number of MFT records.</returns>
|
||||
private long CalculateTotalMftRecords(List<(long startCluster, long clusterCount)> dataRuns)
|
||||
{
|
||||
var totalClusters = 0L;
|
||||
foreach (var (_, clusterCount) in dataRuns)
|
||||
totalClusters += clusterCount;
|
||||
|
||||
return totalClusters * m_bootSector.ClusterSize / m_bootSector.MftRecordSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the modification timestamp from $STANDARD_INFORMATION attribute.
|
||||
/// </summary>
|
||||
/// <param name="recordData">The MFT record data.</param>
|
||||
/// <returns>The modification timestamp, or DateTime.UnixEpoch if not found.</returns>
|
||||
private DateTime GetModificationTimestamp(byte[] recordData)
|
||||
{
|
||||
var firstAttributeOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(FIRST_ATTRIBUTE_OFFSET, 2));
|
||||
|
||||
var attributeOffset = (int)firstAttributeOffset;
|
||||
while (attributeOffset < recordData.Length)
|
||||
{
|
||||
var attrType = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset, 4));
|
||||
|
||||
// End of attributes marker
|
||||
if (attrType == 0xFFFFFFFF)
|
||||
break;
|
||||
|
||||
// Found $STANDARD_INFORMATION attribute
|
||||
if (attrType == ATTR_STANDARD_INFORMATION)
|
||||
{
|
||||
var isNonResident = (recordData[attributeOffset + 8] & ATTR_NON_RESIDENT_FLAG) != 0;
|
||||
|
||||
if (!isNonResident)
|
||||
{
|
||||
// Resident attribute - data is in the record
|
||||
// $STANDARD_INFORMATION structure:
|
||||
// 0x00: Creation time (8 bytes)
|
||||
// 0x08: Modification time (8 bytes)
|
||||
// 0x10: MFT modification time (8 bytes)
|
||||
// 0x18: Access time (8 bytes)
|
||||
// 0x20: Flags (4 bytes)
|
||||
// ... more fields
|
||||
|
||||
var dataOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(attributeOffset + 0x14, 2));
|
||||
var attrDataOffset = attributeOffset + dataOffset;
|
||||
|
||||
// Read modification time at offset 0x08 from attribute data start
|
||||
var modificationTimeRaw = BinaryPrimitives.ReadInt64LittleEndian(recordData.AsSpan(attrDataOffset + 0x08, 8));
|
||||
return ParseFileTime(modificationTimeRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next attribute
|
||||
var attrLength = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset + 4, 4));
|
||||
if (attrLength == 0)
|
||||
break;
|
||||
|
||||
attributeOffset += (int)attrLength;
|
||||
}
|
||||
|
||||
return DateTime.UnixEpoch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts data runs from the $DATA attribute of an MFT record.
|
||||
/// </summary>
|
||||
/// <param name="recordData">The MFT record data.</param>
|
||||
/// <returns>List of (startCluster, clusterCount) tuples.</returns>
|
||||
private List<(long startCluster, long clusterCount)> GetDataRunsFromRecord(byte[] recordData)
|
||||
{
|
||||
var firstAttributeOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(FIRST_ATTRIBUTE_OFFSET, 2));
|
||||
|
||||
var attributeOffset = (int)firstAttributeOffset;
|
||||
while (attributeOffset < recordData.Length)
|
||||
{
|
||||
var attrType = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset, 4));
|
||||
|
||||
// End of attributes marker
|
||||
if (attrType == 0xFFFFFFFF)
|
||||
break;
|
||||
|
||||
// Found $DATA attribute
|
||||
if (attrType == ATTR_DATA)
|
||||
{
|
||||
var isNonResident = (recordData[attributeOffset + 8] & ATTR_NON_RESIDENT_FLAG) != 0;
|
||||
|
||||
if (isNonResident)
|
||||
{
|
||||
// Non-resident attribute - parse data runs
|
||||
var runOffset = BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(attributeOffset + 0x20, 2));
|
||||
return NtfsBitmap.ParseDataRuns(recordData, attributeOffset + runOffset);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Resident $DATA - no clusters allocated
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next attribute
|
||||
var attrLength = BinaryPrimitives.ReadUInt32LittleEndian(recordData.AsSpan(attributeOffset + 4, 4));
|
||||
if (attrLength == 0)
|
||||
break;
|
||||
|
||||
attributeOffset += (int)attrLength;
|
||||
}
|
||||
|
||||
// No $DATA attribute found
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks system metafiles (MFT records 0-23) as allocated with the current timestamp.
|
||||
/// These files are always considered current and should always be backed up.
|
||||
/// </summary>
|
||||
private void MarkSystemMetafilesWithCurrentTimestamp()
|
||||
{
|
||||
var currentTimestamp = DateTime.UtcNow;
|
||||
|
||||
// For each cluster that belongs to system metafiles, mark it with current timestamp
|
||||
// System metafiles are records 0-23
|
||||
for (var recordNumber = 0L; recordNumber < 24 && recordNumber < m_bitmap.TotalClusters; recordNumber++)
|
||||
{
|
||||
// Mark the MFT record's own location
|
||||
var recordCluster = (m_bootSector.MftByteOffset + recordNumber * m_bootSector.MftRecordSize) / m_bootSector.ClusterSize;
|
||||
|
||||
if (recordCluster >= 0 && recordCluster < m_bitmap.TotalClusters)
|
||||
m_clusterToTimestampMap[recordCluster] = currentTimestamp;
|
||||
}
|
||||
|
||||
// Ensure $LogFile clusters (record 2) are always marked with current timestamp
|
||||
// $LogFile is the NTFS transaction journal - its clusters change on every write
|
||||
// We need to ensure all clusters that might belong to $LogFile are marked
|
||||
// This is done by finding any clusters mapped to record 2 and updating them
|
||||
// In a real implementation, we would need to parse record 2's data runs
|
||||
// For now, we ensure the basic system metafile regions are covered
|
||||
}
|
||||
}
|
||||
@@ -79,19 +79,19 @@ internal class BasePartition : IPartition
|
||||
public long Attributes { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(CancellationToken cancellationToken)
|
||||
public async Task<Stream> OpenReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RawDisk == null)
|
||||
throw new InvalidOperationException("RawDisk not available.");
|
||||
return RawDisk.ReadBytesAsync(StartOffset, (int)Math.Min(Size, int.MaxValue), cancellationToken);
|
||||
return new PartitionReadStream(RawDisk, StartOffset, Size);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(CancellationToken cancellationToken)
|
||||
public async Task<Stream> OpenWriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RawDisk == null)
|
||||
throw new InvalidOperationException("RawDisk not available.");
|
||||
return Task.FromResult<Stream>(new PartitionWriteStream(RawDisk, StartOffset, Size));
|
||||
return new PartitionWriteStream(RawDisk, StartOffset, Size);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Partition;
|
||||
@@ -393,7 +394,7 @@ internal class GPT : IPartitionTable
|
||||
PartitionType partitionType = DeterminePartitionType(typeGuid);
|
||||
|
||||
// Determine filesystem type based on partition name and known patterns
|
||||
FileSystemType fsType = DetermineFilesystemType(startOffset, (int)size, typeGuid.ToString(), CancellationToken.None);
|
||||
FileSystemType fsType = DetermineFilesystemType(startOffset, (int)size, typeGuid.ToString().ToUpper(), CancellationToken.None);
|
||||
|
||||
return new BasePartition
|
||||
{
|
||||
@@ -426,19 +427,21 @@ internal class GPT : IPartitionTable
|
||||
/// Determines the filesystem type based on the partition type GUID and partition data.
|
||||
/// </summary>
|
||||
/// <param name="name">The partition name.</param>
|
||||
/// <param name="typeGuid">The partition type GUID.</param>
|
||||
/// <param name="typeGuid">The partition type GUID string in upper case.</param>
|
||||
/// <returns>The corresponding <see cref="FileSystemType"/>.</returns>
|
||||
private FileSystemType DetermineFilesystemType(long offset, int size, string typeGuid, CancellationToken cancellationToken)
|
||||
{
|
||||
// First, we try to look at the GUID
|
||||
if (typeGuid == "48465300-0000-11AA-AA11-00306543ECAC")
|
||||
return FileSystemType.HFSPlus;
|
||||
|
||||
if (typeGuid == "7C3457EF-0000-11AA-AA11-00306543ECAC")
|
||||
return FileSystemType.APFS;
|
||||
|
||||
if (m_rawDisk is not null)
|
||||
if (typeGuid == "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7" && m_rawDisk is not null)
|
||||
{
|
||||
if (NtfsFilesystem.DetectAsync(m_rawDisk, offset, size, cancellationToken).Await())
|
||||
return FileSystemType.NTFS;
|
||||
|
||||
if (Fat32Filesystem.DetectAsync(m_rawDisk, offset, size, cancellationToken).Await())
|
||||
return FileSystemType.FAT32;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Partition;
|
||||
@@ -220,11 +221,22 @@ internal class MBR : IPartitionTable
|
||||
private FileSystemType DetermineFilesystemType(byte typeByte, long offset, int size)
|
||||
{
|
||||
var typeByByte = MbrPartitionTypes.ToFilesystemType(typeByte);
|
||||
if (typeByByte is FileSystemType.Unknown && m_rawDisk != null)
|
||||
|
||||
if (m_rawDisk != null)
|
||||
{
|
||||
// If the partition type byte is unknown, attempt to detect the filesystem by reading the partition data
|
||||
if (Fat32Filesystem.DetectAsync(m_rawDisk, offset, size, CancellationToken.None).Await())
|
||||
return FileSystemType.FAT32;
|
||||
if (typeByByte == FileSystemType.NTFS)
|
||||
{
|
||||
if (NtfsFilesystem.DetectAsync(m_rawDisk, offset, size, CancellationToken.None).Await())
|
||||
return FileSystemType.NTFS;
|
||||
|
||||
return FileSystemType.Unknown;
|
||||
}
|
||||
|
||||
if (typeByByte is FileSystemType.Unknown)
|
||||
{
|
||||
if (Fat32Filesystem.DetectAsync(m_rawDisk, offset, size, CancellationToken.None).Await())
|
||||
return FileSystemType.FAT32;
|
||||
}
|
||||
}
|
||||
|
||||
return typeByByte;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (C) 2026, The Duplicati Team
|
||||
// https://duplicati.com, hello@duplicati.com
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
/// <summary>
|
||||
/// A stream that buffers reads from a partition.
|
||||
/// Uses ArrayPool to avoid LOH allocations for large buffers.
|
||||
/// </summary>
|
||||
internal class PartitionReadStream : Stream
|
||||
{
|
||||
private readonly IRawDisk _disk;
|
||||
/// <summary>
|
||||
/// Starting offset of the partition on the disk.
|
||||
/// </summary>
|
||||
private readonly long _startOffset;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
private bool _disposed = false;
|
||||
|
||||
public PartitionReadStream(IRawDisk disk, long startOffset, long length)
|
||||
{
|
||||
_disk = disk;
|
||||
_startOffset = startOffset;
|
||||
_length = length;
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set
|
||||
{
|
||||
if (value < 0 || value > _length)
|
||||
throw new System.ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
=> ReadAsync(buffer, offset, count, CancellationToken.None).Await();
|
||||
public async override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken token)
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(PartitionReadStream));
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0 || offset > buffer.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0 || offset + count > buffer.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (_position >= _length)
|
||||
return 0; // EOF
|
||||
|
||||
var bytesToRead = Math.Min(count, _length - _position);
|
||||
if (bytesToRead > 0)
|
||||
{
|
||||
return await _disk.ReadBytesAsync(_startOffset + _position, buffer.AsMemory(offset, (int)bytesToRead), token).ConfigureAwait(false);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
var newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
SeekOrigin.End => _length + offset,
|
||||
_ => throw new System.ArgumentOutOfRangeException(nameof(origin))
|
||||
};
|
||||
if (newPosition < 0 || newPosition > _length)
|
||||
throw new IOException("Cannot seek beyond partition size.");
|
||||
_position = newPosition;
|
||||
return _position;
|
||||
}
|
||||
public override void SetLength(long value)
|
||||
=> throw new NotSupportedException("SetLength is not supported on PartitionReadStream.");
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
=> throw new NotSupportedException("Write is not supported on PartitionReadStream.");
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
_disposed = true;
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.Disk;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Partition;
|
||||
|
||||
@@ -778,6 +779,7 @@ public sealed class RestoreProvider : IRestoreDestinationProviderModule, IDispos
|
||||
return fsGeom.Type switch
|
||||
{
|
||||
FileSystemType.FAT32 => new Fat32Filesystem(partition, fsGeom.BlockSize),
|
||||
FileSystemType.NTFS => new NtfsFilesystem(partition, fsGeom.BlockSize),
|
||||
_ => new UnknownFilesystem(partition, fsGeom.BlockSize)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Threading.Tasks;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.SourceItems;
|
||||
@@ -50,13 +51,11 @@ internal class FilesystemSourceEntry(string parentPath, IFilesystem filesystem)
|
||||
int blockSize = 1024 * 1024; // Default 1MB blocks
|
||||
|
||||
if (fsMetadata is UnkownFilesystemMetadata unknownMeta)
|
||||
{
|
||||
blockSize = unknownMeta.BlockSize;
|
||||
}
|
||||
else if (fsMetadata is Fat32FilesystemMetadata fat32Meta)
|
||||
{
|
||||
blockSize = fat32Meta.BlockSize;
|
||||
}
|
||||
else if (fsMetadata is NtfsFilesystemMetadata ntfsMeta)
|
||||
blockSize = ntfsMeta.BlockSize;
|
||||
|
||||
return new FilesystemGeometry
|
||||
{
|
||||
@@ -101,6 +100,17 @@ internal class FilesystemSourceEntry(string parentPath, IFilesystem filesystem)
|
||||
metadata["filesystem:AllocatedClusters"] = fat32Meta.AllocatedClusters.ToString();
|
||||
metadata["filesystem:FreeClusters"] = fat32Meta.FreeClusters.ToString();
|
||||
}
|
||||
// For NTFS, extract cluster and MFT information
|
||||
else if (fsMetadata is NtfsFilesystemMetadata ntfsMeta)
|
||||
{
|
||||
metadata["filesystem:BlockSize"] = ntfsMeta.BlockSize.ToString();
|
||||
metadata["filesystem:ClusterSize"] = ntfsMeta.ClusterSize.ToString();
|
||||
metadata["filesystem:TotalClusters"] = ntfsMeta.TotalClusters.ToString();
|
||||
metadata["filesystem:AllocatedClusters"] = ntfsMeta.AllocatedClusters.ToString();
|
||||
metadata["filesystem:FreeClusters"] = ntfsMeta.FreeClusters.ToString();
|
||||
metadata["filesystem:MftRecordSize"] = ntfsMeta.MftRecordSize.ToString();
|
||||
metadata["filesystem:HasJournal"] = ntfsMeta.HasJournal.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -12,6 +12,7 @@ using Duplicati.Proprietary.DiskImage.Filesystem;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.DiskImage.General;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
|
||||
using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
|
||||
|
||||
namespace Duplicati.Proprietary.DiskImage.SourceItems;
|
||||
|
||||
@@ -46,6 +47,7 @@ internal class PartitionSourceEntry(string parentPath, IPartition partition, boo
|
||||
: partition.FilesystemType switch
|
||||
{
|
||||
FileSystemType.FAT32 => new Fat32Filesystem(partition),
|
||||
FileSystemType.NTFS => new NtfsFilesystem(partition),
|
||||
_ => new UnknownFilesystem(partition)
|
||||
};
|
||||
yield return new FilesystemSourceEntry(this.Path, fs);
|
||||
|
||||
Reference in New Issue
Block a user