From 5fa136bccf6ad1c1671a1901dbdca60dc391d46d Mon Sep 17 00:00:00 2001 From: Carl Johnsen Date: Fri, 13 Mar 2026 09:21:48 +0100 Subject: [PATCH] Added inecremental FAT32 integration test --- .../UnitTest/DiskImage/DiskImageTests.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs index 4d8dc5065..9a656136c 100644 --- a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs +++ b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs @@ -1260,6 +1260,164 @@ namespace Duplicati.UnitTest.DiskImage #region FAT32 Filesystem-Aware Integration Tests + /// + /// Tests that incremental backup with FAT32 skips unchanged blocks. + /// Creates a FAT32 disk, performs initial backup, modifies one file, + /// performs second backup, and verifies only changed blocks were processed. + /// + [Test] + [Category("DiskImage")] + [Category("DiskImageFAT32")] + public async Task Test_FAT32_IncrementalBackup_UnchangedBlocks_Skipped() + { + await TestContext.Progress.WriteLineAsync("Test: FAT32 Incremental Backup - Unchanged Blocks Skipped"); + + // Create source disk with FAT32 partition + var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (int)(50 * MiB)); + var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]); + await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}"); + + // Generate initial test data + await ToolTests.GenerateTestData(sourcePartitions.First(), 10, 3, 1, 1024); + _diskHelper.FlushDisk(sourceDrivePath); + _diskHelper.Unmount(sourceDrivePath); + await TestContext.Progress.WriteLineAsync("Initial test data generated"); + + // First backup + var firstBackupResults = RunBackup(sourceDrivePath); + TestUtils.AssertResults(firstBackupResults); + var firstBackupSize = firstBackupResults.SizeOfOpenedFiles; + await TestContext.Progress.WriteLineAsync($"First backup completed: {firstBackupResults.OpenedFiles} files opened, {firstBackupSize} bytes"); + + // 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, (int)(50 * MiB)); + _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []); + _diskHelper.Unmount(restoreDrivePath); + await TestContext.Progress.WriteLineAsync($"Restore disk created at: {_restoreImagePath}"); + + var restoreResults = RunRestore(restoreDrivePath); + TestUtils.AssertResults(restoreResults); + await TestContext.Progress.WriteLineAsync("Initial restore completed successfully"); + + // Reattach and verify initial restore + sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, PartitionTableType.GPT, readOnly: true); + restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, PartitionTableType.GPT, readOnly: true); + + var fsTypes = new[] { FileSystemType.FAT32 }; + 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("Initial restore verified successfully"); + + // Unmount both disks to prepare for modification + _diskHelper.Unmount(sourceDrivePath); + _diskHelper.Unmount(restoreDrivePath); + await TestContext.Progress.WriteLineAsync("Both disks unmounted"); + + // Remount source drive in write-enabled mode + sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, PartitionTableType.GPT, readOnly: false); + sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: false, fileSystemTypes: fsTypes); + await TestContext.Progress.WriteLineAsync("Source disk remounted in write mode"); + + // Modify a few files on the source - create new files and modify existing ones + var sourcePartitionPath = sourceMounted.First(); + var modifiedFiles = new List(); + + // 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); + await TestContext.Progress.WriteLineAsync($"Created {modifiedFiles.Count} new files in {newDirPath}"); + + // Modify an existing file if one exists + var existingFiles = Directory.GetFiles(sourcePartitionPath, "*.txt", SearchOption.AllDirectories) + .Where(f => !f.Contains("incremental_new_files")) + .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); + await TestContext.Progress.WriteLineAsync($"Modified existing file: {fileToModify}"); + } + + // Flush and unmount source after modifications + _diskHelper.FlushDisk(sourceDrivePath); + _diskHelper.Unmount(sourceDrivePath); + await TestContext.Progress.WriteLineAsync("Modifications complete, source disk unmounted"); + + // Re-attach the source disk for backup (disk needs to be attached for backup to access it) + sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, PartitionTableType.GPT, readOnly: true); + await TestContext.Progress.WriteLineAsync($"Source disk re-attached for second backup at: {sourceDrivePath}"); + + // Second backup (incremental) + var secondBackupResults = RunBackup(sourceDrivePath); + TestUtils.AssertResults(secondBackupResults); + var secondBackupSize = secondBackupResults.SizeOfOpenedFiles; + await TestContext.Progress.WriteLineAsync($"Second backup completed: {secondBackupResults.OpenedFiles} files opened, {secondBackupSize} bytes examined, {secondBackupResults.SizeOfAddedFiles} bytes added"); + + // 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; + await TestContext.Progress.WriteLineAsync($"File reduction: {fileReductionRatio:P2} ({secondBackupResults.OpenedFiles}/{firstBackupResults.OpenedFiles})"); + await TestContext.Progress.WriteLineAsync($"Size reduction: {sizeReductionRatio:P2} ({secondBackupResults.SizeOfOpenedFiles}/{firstBackupResults.SizeOfOpenedFiles})"); + + // Restore the incremental backup to a fresh disk + _diskHelper.CleanupDisk(_restoreImagePath); + DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath); + + restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, (int)(50 * MiB)); + _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []); + _diskHelper.Unmount(restoreDrivePath); + await TestContext.Progress.WriteLineAsync($"Fresh restore disk created for incremental restore"); + + var incrementalRestoreResults = RunRestore(restoreDrivePath); + TestUtils.AssertResults(incrementalRestoreResults); + await TestContext.Progress.WriteLineAsync("Incremental restore completed successfully"); + + // Reattach and verify the incremental restore matches the modified source + sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, PartitionTableType.GPT, readOnly: true); + restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, PartitionTableType.GPT, 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()); + + await TestContext.Progress.WriteLineAsync("FAT32 incremental backup and restore verified successfully"); + } + /// /// Tests that unallocated space in FAT32 compresses well due to zero-block deduplication. /// Creates a FAT32 disk with sparse data (few files, lots of free space),