Files
duplicati/Duplicati/UnitTest/DiskImage/Fat32/Fat32DirectoryWalkerTests.cs
T

740 lines
29 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
using Duplicati.Proprietary.DiskImage.Filesystem;
using Duplicati.Proprietary.DiskImage.Filesystem.Fat32;
using NUnit.Framework;
using NUnit.Framework.Legacy;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
#nullable enable
namespace Duplicati.UnitTest.DiskImage.Fat32;
/// <summary>
/// Unit tests for the <see cref="Fat32DirectoryWalker"/> directory entry walker.
/// </summary>
[TestFixture]
[Category("DiskImageUnit")]
public class Fat32DirectoryWalkerTests
{
/// <summary>
/// Creates a valid FAT32 boot sector with known values for testing.
/// </summary>
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 = 4KB clusters)
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 (10,000 sectors = ~5MB)
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 10_000);
// FAT size 32 at offset 0x24 (10 sectors per FAT)
BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 10);
// 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: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16)
BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
return bootSector;
}
/// <summary>
/// Creates FAT data with specified entries.
/// </summary>
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;
}
/// <summary>
/// Creates a directory entry (32 bytes) with the specified parameters.
/// </summary>
private static byte[] CreateDirectoryEntry(
string filename,
byte attributes,
ushort writeDate,
ushort writeTime,
ushort clusterHigh,
ushort clusterLow,
uint fileSize = 0)
{
var entry = new byte[32];
// Filename (11 bytes: 8 for name, 3 for extension)
var nameBytes = System.Text.Encoding.ASCII.GetBytes(filename.PadRight(11).Substring(0, 11));
nameBytes.CopyTo(entry, 0);
// Attributes at offset 0x0B
entry[0x0B] = attributes;
// Write time at offset 0x16
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x16, 2), writeTime);
// Write date at offset 0x18
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x18, 2), writeDate);
// Cluster high at offset 0x14
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x14, 2), clusterHigh);
// Cluster low at offset 0x1A
BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x1A, 2), clusterLow);
// File size at offset 0x1C
BinaryPrimitives.WriteUInt32LittleEndian(entry.AsSpan(0x1C, 4), fileSize);
return entry;
}
[Test]
public void Test_Fat32DirectoryWalker_ParseTimestamp_Correct()
{
// Test known FAT32 date/time values
// Date: 2023-06-15 = (2023-1980) << 9 | 6 << 5 | 15 = 43 << 9 | 192 | 15 = 22080 + 192 + 15 = 22287
// Time: 14:30:20 = 14 << 11 | 30 << 5 | 10 = 28672 + 960 + 10 = 29642
var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15);
var time = (ushort)((14 << 11) | (30 << 5) | (20 / 2));
var result = Fat32DirectoryWalker.ParseDateTime(date, time);
Assert.AreEqual(2023, result.Year);
Assert.AreEqual(6, result.Month);
Assert.AreEqual(15, result.Day);
Assert.AreEqual(14, result.Hour);
Assert.AreEqual(30, result.Minute);
Assert.AreEqual(20, result.Second);
Assert.AreEqual(DateTimeKind.Utc, result.Kind);
}
[Test]
public void Test_Fat32DirectoryWalker_ParseTimestamp_Zero_ReturnsEpoch()
{
var result = Fat32DirectoryWalker.ParseDateTime(0, 0);
Assert.AreEqual(DateTime.UnixEpoch, result);
}
[Test]
public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidDate_ReturnsEpoch()
{
// Invalid month (13)
var invalidDate = (ushort)((43 << 9) | (13 << 5) | 15);
var time = (ushort)((14 << 11) | (30 << 5) | 10);
var result = Fat32DirectoryWalker.ParseDateTime(invalidDate, time);
Assert.AreEqual(DateTime.UnixEpoch, result);
}
[Test]
public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidTime_ReturnsEpoch()
{
// Valid date but invalid hour (25)
var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15);
var invalidTime = (ushort)((25 << 11) | (30 << 5) | 10);
var result = Fat32DirectoryWalker.ParseDateTime(date, invalidTime);
Assert.AreEqual(DateTime.UnixEpoch, result);
}
[Test]
public void Test_Fat32DirectoryWalker_SkipDeletedEntries()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF, // Cluster 3: valid entry (EOC)
0x0FFFFFFF // Cluster 4: valid entry (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory cluster data with a deleted entry followed by a valid entry
var deletedEntry = CreateDirectoryEntry(
"DELETEDTXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
3, // Would point to cluster 3
100);
deletedEntry[0] = 0xE5; // Mark as deleted
var validEntry = CreateDirectoryEntry(
"VALID FIL",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0,
4, // Points to cluster 4
200);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. deletedEntry, .. validEntry, .. new byte[4096 - 64]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should only have cluster 4 mapped (cluster 3 should not be mapped as the entry was deleted)
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Deleted entry's cluster should not be mapped");
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped");
}
[Test]
public void Test_Fat32DirectoryWalker_StopAtEndMarker()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF, // Cluster 3: valid entry (EOC)
0x0FFFFFFF // Cluster 4: valid entry (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory cluster data with an end marker followed by a valid entry
var validEntry1 = CreateDirectoryEntry(
"FILE1 TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
3, // Points to cluster 3
100);
var endMarker = new byte[32]; // All zeros including first byte = end of directory
var validEntry2 = CreateDirectoryEntry(
"FILE2 TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0,
4, // Points to cluster 4
200);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. validEntry1, .. endMarker, .. validEntry2, .. new byte[4096 - 96]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should only have cluster 3 mapped (FILE2 after end marker should be ignored)
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Entry before end marker should be mapped");
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(4), "Entry after end marker should not be mapped");
}
[Test]
public void Test_Fat32DirectoryWalker_SkipLfnEntries()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF // Cluster 3: valid entry (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory cluster data with an LFN entry followed by a valid short entry
var lfnEntry = CreateDirectoryEntry(
"LFN_ENTRY ",
0x0F, // Long filename attribute
0,
0,
0,
0,
0);
var validEntry = CreateDirectoryEntry(
"VALID FIL",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0,
3, // Points to cluster 3
200);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. lfnEntry, .. validEntry, .. new byte[4096 - 64]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should only have cluster 3 mapped (LFN entry should be skipped)
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "LFN entry should not create mapping");
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped");
}
[Test]
public void Test_Fat32DirectoryWalker_SkipDotEntries()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) and cluster 3 (subdir) both point to EOC, with enough entries for cluster 4
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF, // Cluster 3: subdirectory (EOC)
0x0FFFFFFF // Cluster 4: valid entry (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory with a subdirectory entry
var subdirEntry = CreateDirectoryEntry(
"SUBDIR ",
0x10, // Directory attribute
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
3, // Points to cluster 3
0);
var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray();
// Create subdirectory cluster with . and .. entries and a file entry
var dotEntry = CreateDirectoryEntry(
". ",
0x10, // Directory attribute
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
3, // Points to self (cluster 3)
0);
var dotDotEntry = CreateDirectoryEntry(
".. ",
0x10, // Directory attribute
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
2, // Points to parent (cluster 2)
0);
var fileEntry = CreateDirectoryEntry(
"FILE TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0,
4, // Points to cluster 4
100);
var subdirClusterData = dotEntry.Concat(dotDotEntry).Concat(fileEntry).Concat(new byte[4096 - 96]).ToArray();
var clusterData = new Dictionary<uint, byte[]>
{
[2] = rootClusterData,
[3] = subdirClusterData
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Cluster 2 should be mapped (it's the root directory)
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped");
// Cluster 3 should be mapped (it's the subdirectory itself)
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Subdirectory cluster should be mapped");
// Cluster 4 should be mapped (it's the file in the subdirectory)
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "File cluster should be mapped");
// We should have exactly 3 mappings (clusters 2, 3, and 4)
Assert.AreEqual(3, walker.ClusterToTimestampMap.Count, "Should only have 3 cluster mappings");
}
[Test]
public void Test_Fat32DirectoryWalker_ExtractClusterNumber()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) and cluster 0x1234ABCD both point to EOC
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x00000000, // Cluster 3: free (not used in this test)
0x00000000, // ... more free clusters
0x00000000,
0x00000000,
0x00000000,
0x00000000,
0x00000000,
0x0FFFFFFF // Cluster 10 (0x0A): high cluster test (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Test with a file entry that has a large cluster number requiring both high and low words
// Cluster 0x0A = 10 (within our small FAT)
var fileEntry = CreateDirectoryEntry(
"TEST FIL",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0x0000, // High word
0x000A, // Low word = 10
100);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. fileEntry, .. new byte[4096 - 32]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Cluster 10 should be mapped
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(10), "Cluster with combined high/low words should be mapped");
}
[Test]
public void Test_Fat32DirectoryWalker_ClusterTimestampMap_Correct()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT with cluster chains
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x00000004, // Cluster 3: points to cluster 4
0x0FFFFFFF, // Cluster 4: EOC (file data)
0x00000006, // Cluster 5: points to cluster 6
0x00000007, // Cluster 6: points to cluster 7
0x0FFFFFFF // Cluster 7: EOC (file data)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory with two file entries
var file1Entry = CreateDirectoryEntry(
"FILE1 TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
3, // Points to cluster 3 (chain: 3 -> 4)
8192); // 2 clusters worth of data
var file2Entry = CreateDirectoryEntry(
"FILE2 DAT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((16 << 11) | (45 << 5) | (30 / 2)),
0,
5, // Points to cluster 5 (chain: 5 -> 6 -> 7)
12288); // 3 clusters worth of data
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. file1Entry, .. file2Entry, .. new byte[4096 - 64]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should have mappings for all file clusters (3, 4, 5, 6, 7) plus root directory (2)
Assert.AreEqual(6, walker.ClusterToTimestampMap.Count, "Should have 6 cluster mappings (root + 5 file clusters)");
// Verify root directory cluster (2) is mapped
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped");
// Verify FILE1 clusters (3 and 4) have the FILE1 timestamp
var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc);
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[3], "Cluster 3 should have FILE1 timestamp");
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Cluster 4 should have FILE1 timestamp");
// Verify FILE2 clusters (5, 6, 7) have the FILE2 timestamp
var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc);
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[5], "Cluster 5 should have FILE2 timestamp");
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Cluster 6 should have FILE2 timestamp");
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[7], "Cluster 7 should have FILE2 timestamp");
}
[Test]
public void Test_Fat32DirectoryWalker_SubdirectoryRecursion()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT with cluster chains for root and subdirectory
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF, // Cluster 3: subdirectory (EOC)
0x00000005, // Cluster 4: file in subdirectory points to cluster 5
0x0FFFFFFF, // Cluster 5: EOC (file data)
0x0FFFFFFF // Cluster 6: another file in subdirectory (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory with a subdirectory entry
var subdirEntry = CreateDirectoryEntry(
"SUBDIR ",
0x10, // Directory attribute
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 10),
(ushort)((9 << 11) | (0 << 5) | 0),
0,
3, // Points to cluster 3
0);
var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray();
// Create subdirectory cluster with two file entries
var subdirFile1Entry = CreateDirectoryEntry(
"SUBFILE1TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
4, // Points to cluster 4 (chain: 4 -> 5)
8192);
var subdirFile2Entry = CreateDirectoryEntry(
"SUBFILE2DAT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((16 << 11) | (45 << 5) | (30 / 2)),
0,
6, // Points to cluster 6
4096);
var subdirClusterData = subdirFile1Entry.Concat(subdirFile2Entry).Concat(new byte[4096 - 64]).ToArray();
var clusterData = new Dictionary<uint, byte[]>
{
[2] = rootClusterData,
[3] = subdirClusterData
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should have mappings for:
// - Root directory cluster (2)
// - Subdirectory cluster (3) with its own timestamp
// - File1 clusters (4, 5) with their timestamp
// - File2 cluster (6) with its timestamp
Assert.AreEqual(5, walker.ClusterToTimestampMap.Count, "Should have 5 cluster mappings (root + subdir + file clusters)");
// Verify root directory cluster is mapped
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped");
// Verify subdirectory cluster has the subdirectory timestamp
var subdirExpectedTime = new DateTime(2023, 6, 10, 9, 0, 0, DateTimeKind.Utc);
Assert.AreEqual(subdirExpectedTime, walker.ClusterToTimestampMap[3], "Subdirectory cluster should have subdirectory timestamp");
// Verify subfile1 clusters have their timestamp
var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc);
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Subfile1 cluster 4 should have file timestamp");
Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[5], "Subfile1 cluster 5 should have file timestamp");
// Verify subfile2 cluster has its timestamp
var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc);
Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Subfile2 cluster should have file timestamp");
}
[Test]
public void Test_Fat32DirectoryWalker_OverlappingClusters_UsesMostRecentTimestamp()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 3 is referenced by both files
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF, // Cluster 3: EOC (shared by both files - unusual but testable)
0x0FFFFFFF, // Cluster 4: EOC
0x0FFFFFFF // Cluster 5: EOC
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory with two file entries referencing the same cluster
// This is unusual in practice but tests the "most recent timestamp" logic
var olderFileEntry = CreateDirectoryEntry(
"OLDER TXT",
0x00, // Regular file
(ushort)(((2020 - 1980) << 9) | (1 << 5) | 1),
(ushort)((0 << 11) | (0 << 5) | 0),
0,
3, // Points to cluster 3
4096);
var newerFileEntry = CreateDirectoryEntry(
"NEWER TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((16 << 11) | (45 << 5) | (30 / 2)),
0,
3, // Also points to cluster 3
4096);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. olderFileEntry, .. newerFileEntry, .. new byte[4096 - 64]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Cluster 3 should have the newer timestamp
var newerExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc);
Assert.AreEqual(newerExpectedTime, walker.ClusterToTimestampMap[3], "Shared cluster should have most recent timestamp");
}
[Test]
public void Test_Fat32DirectoryWalker_SkipVolumeLabel()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF, // Cluster 3: volume label would point here (but should be skipped)
0x0FFFFFFF // Cluster 4: valid entry (EOC)
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory cluster data with a volume label followed by a valid file entry
var volumeLabelEntry = CreateDirectoryEntry(
"VOLUMELABEL",
0x08, // Volume label attribute
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
3, // Would point to cluster 3
0);
var validEntry = CreateDirectoryEntry(
"VALID FIL",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0,
4, // Points to cluster 4
200);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. volumeLabelEntry, .. validEntry, .. new byte[4096 - 64]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should only have cluster 4 mapped (volume label should be skipped)
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Volume label's cluster should not be mapped");
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped");
}
[Test]
public void Test_Fat32DirectoryWalker_InvalidClusterNumber_Skipped()
{
var bootSectorData = CreateValidBootSector();
var bootSector = new Fat32BootSector(bootSectorData);
// Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3
var fatData = CreateFatData(
0x00000000, // Cluster 0: reserved
0x0FFFFFFF, // Cluster 1: reserved (EOC marker)
0x0FFFFFFF, // Cluster 2: root directory (EOC)
0x0FFFFFFF // Cluster 3: valid entry (EOC) - for the valid file
);
var fatTable = new Fat32Table(bootSector, fatData);
// Create root directory cluster data with an entry that has cluster 0 (invalid)
var invalidClusterEntry = CreateDirectoryEntry(
"INVALID TXT",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 15),
(ushort)((14 << 11) | (30 << 5) | 10),
0,
0, // Cluster 0 is invalid (reserved)
100);
var validEntry = CreateDirectoryEntry(
"VALID FIL",
0x00, // Regular file
(ushort)(((2023 - 1980) << 9) | (6 << 5) | 20),
(ushort)((10 << 11) | (0 << 5) | 0),
0,
3, // Points to cluster 3
200);
var clusterData = new Dictionary<uint, byte[]>
{
[2] = [.. invalidClusterEntry, .. validEntry, .. new byte[4096 - 64]]
};
byte[] getClusterData(uint clusterNum) => clusterData[clusterNum];
var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData);
// Should only have cluster 3 mapped (invalid cluster 0 should be skipped)
Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "Invalid cluster 0 should not be mapped");
Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped");
}
}