diff --git a/Duplicati/UnitTest/DiskImage/Fat32TableTests.cs b/Duplicati/UnitTest/DiskImage/Fat32TableTests.cs new file mode 100644 index 000000000..24ad09fa2 --- /dev/null +++ b/Duplicati/UnitTest/DiskImage/Fat32TableTests.cs @@ -0,0 +1,351 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using Duplicati.Proprietary.DiskImage.Filesystem; +using NUnit.Framework; +using NUnit.Framework.Legacy; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +#nullable enable + +namespace Duplicati.UnitTest.DiskImage +{ + /// + /// Unit tests for the FAT table reader. + /// + [TestFixture] + [Category("DiskImageUnit")] + public class Fat32TableTests + { + /// + /// Creates a valid FAT32 boot sector with known values for testing. + /// + private static byte[] CreateValidBootSector() + { + var bootSector = new byte[512]; + + // 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; + + // Reserved sector count at offset 0x0E (32 sectors) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); + + // Number of FATs at offset 0x10 (2 FATs) + bootSector[0x10] = 2; + + // Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000); + + // FAT size 32 at offset 0x24 (244 sectors per FAT) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244); + + // Root cluster at offset 0x2C (cluster 2) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); + + // FSInfo sector at offset 0x30 (sector 1) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); + + // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") + var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 "); + fsType.CopyTo(bootSector, 0x52); + + // Boot sector signature at offset 510 (0x55AA) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0x55AA); + + return bootSector; + } + + /// + /// Creates FAT data with specified entries. + /// + private static byte[] CreateFatData(params uint[] entries) + { + var fatData = new byte[entries.Length * 4]; + for (int i = 0; i < entries.Length; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]); + } + return fatData; + } + + [Test] + public void Test_Fat32Table_FreeCluster_NotAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 as free + var fatData = CreateFatData(0, 0, Fat32Table.FREE_CLUSTER); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsFalse(fatTable.IsClusterAllocated(2), "Free cluster should not be allocated"); + } + + [Test] + public void Test_Fat32Table_AllocatedCluster_IsAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 pointing to cluster 3 (allocated) + var fatData = CreateFatData(0, 0, 3, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster with valid next-cluster value should be allocated"); + Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster with EOC should be allocated"); + } + + [Test] + public void Test_Fat32Table_EndOfChain_IsAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Test all EOC values (0x0FFFFFF8 - 0x0FFFFFFF) + for (uint eocValue = Fat32Table.EOC_MIN; eocValue <= Fat32Table.EOC_MAX; eocValue++) + { + var fatData = CreateFatData(0, 0, eocValue); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsTrue(fatTable.IsClusterAllocated(2), $"Cluster with EOC value 0x{eocValue:X8} should be allocated"); + } + } + + [Test] + public void Test_Fat32Table_BadCluster_NotAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 as bad + var fatData = CreateFatData(0, 0, Fat32Table.BAD_CLUSTER); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsFalse(fatTable.IsClusterAllocated(2), "Bad cluster should not be allocated"); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_SingleCluster() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 having EOC immediately + var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(2); + + Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry"); + Assert.AreEqual(2u, chain[0], "Chain should contain the start cluster"); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_MultipleClusters() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with chain: 2 -> 3 -> 4 -> EOC + var fatData = CreateFatData(0, 0, 3, 4, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(2); + + Assert.AreEqual(3, chain.Count, "Chain should have 3 entries"); + Assert.AreEqual(2u, chain[0], "First entry should be cluster 2"); + Assert.AreEqual(3u, chain[1], "Second entry should be cluster 3"); + Assert.AreEqual(4u, chain[2], "Third entry should be cluster 4"); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_CircularDetection() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with circular chain: 2 -> 3 -> 2 (circular) + var fatData = CreateFatData(0, 0, 3, 2); + var fatTable = new Fat32Table(bootSector, fatData); + + var ex = Assert.Throws(() => fatTable.GetClusterChain(2)); + StringAssert.Contains("Circular", ex!.Message); + } + + [Test] + public void Test_Fat32Table_AllocationBitmap_MatchesFatEntries() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with mixed entries: + // 0: reserved (typically EOC or 0) + // 1: reserved (typically EOC or 0) + // 2: free + // 3: allocated (points to 4) + // 4: EOC (end of chain) + // 5: bad cluster + var fatData = CreateFatData( + Fat32Table.FREE_CLUSTER, // cluster 0 + Fat32Table.FREE_CLUSTER, // cluster 1 + Fat32Table.FREE_CLUSTER, // cluster 2 (free) + 4, // cluster 3 (allocated, points to 4) + Fat32Table.EOC_MIN, // cluster 4 (end of chain) + Fat32Table.BAD_CLUSTER // cluster 5 (bad) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Reserved clusters 0 and 1 - treated as free (not allocated) + Assert.IsFalse(fatTable.IsClusterAllocated(0), "Cluster 0 should not be allocated"); + Assert.IsFalse(fatTable.IsClusterAllocated(1), "Cluster 1 should not be allocated"); + + // Cluster 2 is free + Assert.IsFalse(fatTable.IsClusterAllocated(2), "Cluster 2 (free) should not be allocated"); + + // Cluster 3 is allocated (points to next cluster) + Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster 3 (allocated) should be allocated"); + + // Cluster 4 is EOC + Assert.IsTrue(fatTable.IsClusterAllocated(4), "Cluster 4 (EOC) should be allocated"); + + // Cluster 5 is bad + Assert.IsFalse(fatTable.IsClusterAllocated(5), "Cluster 5 (bad) should not be allocated"); + + // Verify counts + Assert.AreEqual(6u, fatTable.TotalClusters, "Total clusters should be 6"); + Assert.AreEqual(2u, fatTable.AllocatedClusters, "Allocated clusters should be 2 (clusters 3 and 4)"); + Assert.AreEqual(4u, fatTable.FreeClusters, "Free clusters should be 4 (clusters 0, 1, 2, and 5 is counted as free since it's bad)"); + } + + [Test] + public void Test_Fat32Table_EntryMask_IgnoresUpperBits() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 having upper 4 bits set (should be masked) + // 0xF0000003 with mask 0x0FFFFFFF = 0x00000003 (points to cluster 3) + uint entryWithUpperBits = 0xF0000003; + var fatData = CreateFatData(0, 0, entryWithUpperBits, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var nextCluster = fatTable.GetNextCluster(2); + + Assert.AreEqual(3u, nextCluster, "Upper 4 bits should be masked, leaving value 3"); + Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster should be allocated even with upper bits set"); + } + + [Test] + public void Test_Fat32Table_GetNextCluster_ReturnsMaskedValue() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT where cluster 2 points to cluster 5 (0x00000005) + var fatData = CreateFatData(0, 0, 5, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var nextCluster = fatTable.GetNextCluster(2); + + Assert.AreEqual(5u, nextCluster, "GetNextCluster should return the next cluster number"); + } + + [Test] + public void Test_Fat32Table_ClusterOutOfRange_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + // Try to access cluster beyond the FAT size + var ex = Assert.Throws(() => fatTable.IsClusterAllocated(100)); + StringAssert.Contains("out of range", ex!.Message); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_StartClusterOutOfRange_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var ex = Assert.Throws(() => fatTable.GetClusterChain(100)); + StringAssert.Contains("out of range", ex!.Message); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_InvalidClusterInChain_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT where cluster 2 points to cluster 3 which is free (invalid in a chain) + var fatData = CreateFatData(0, 0, 3, Fat32Table.FREE_CLUSTER); + var fatTable = new Fat32Table(bootSector, fatData); + + var ex = Assert.Throws(() => fatTable.GetClusterChain(2)); + StringAssert.Contains("Invalid cluster", ex!.Message); + } + + [Test] + public void Test_Fat32Table_Constructor_NullFatData_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var ex = Assert.Throws(() => new Fat32Table(bootSector, null!)); + Assert.AreEqual("fatData", ex!.ParamName); + } + + [Test] + public void Test_Fat32Table_EmptyClusterChain() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 0 having EOC + var fatData = CreateFatData(Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(0); + + Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry"); + Assert.AreEqual(0u, chain[0], "Chain should contain cluster 0"); + } + + [Test] + public void Test_Fat32Table_LongClusterChain() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a chain of 10 clusters: 2 -> 3 -> 4 -> ... -> 11 -> EOC + var entries = new List { 0, 0 }; // clusters 0 and 1 + for (uint i = 2; i <= 10; i++) + { + entries.Add(i + 1); // each cluster points to the next + } + entries.Add(Fat32Table.EOC_MIN); // cluster 11 is EOC + + var fatData = CreateFatData(entries.ToArray()); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(2); + + Assert.AreEqual(10, chain.Count, "Chain should have 10 entries"); + for (int i = 0; i < 10; i++) + { + Assert.AreEqual((uint)(i + 2), chain[i], $"Chain entry {i} should be cluster {i + 2}"); + } + } + } +}