// 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.Fat32; /// /// Walks the FAT32 directory tree to extract file metadata and build a cluster-to-timestamp map. /// internal class Fat32DirectoryWalker { /// /// Attribute indicating a directory entry. /// private const byte ATTR_DIRECTORY = 0x10; /// /// Attribute indicating a long filename (LFN) entry. /// private const byte ATTR_LONG_FILENAME = 0x0F; /// /// Attribute mask for the volume label. /// private const byte ATTR_VOLUME_ID = 0x08; /// /// First byte indicating a deleted directory entry. /// private const byte DELETED_ENTRY_MARKER = 0xE5; /// /// First byte indicating end of directory (no more entries follow). /// private const byte END_OF_DIRECTORY_MARKER = 0x00; /// /// Offset to the attributes field in a directory entry. /// private const int ENTRY_ATTRIBUTES_OFFSET = 0x0B; /// /// Offset to the creation time field in a directory entry. /// private const int ENTRY_CREATION_TIME_OFFSET = 0x0E; /// /// Offset to the creation date field in a directory entry. /// private const int ENTRY_CREATION_DATE_OFFSET = 0x10; /// /// Offset to the first cluster high word in a directory entry. /// private const int ENTRY_CLUSTER_HIGH_OFFSET = 0x14; /// /// Offset to the last write time field in a directory entry. /// private const int ENTRY_WRITE_TIME_OFFSET = 0x16; /// /// Offset to the last write date field in a directory entry. /// private const int ENTRY_WRITE_DATE_OFFSET = 0x18; /// /// Offset to the first cluster low word in a directory entry. /// private const int ENTRY_CLUSTER_LOW_OFFSET = 0x1A; /// /// Offset to the file size field in a directory entry. /// private const int ENTRY_FILE_SIZE_OFFSET = 0x1C; /// /// Size of a single directory entry in bytes. /// private const int DIRECTORY_ENTRY_SIZE = 32; /// /// The boot sector containing FAT32 geometry information. /// private readonly Fat32BootSector m_bootSector; /// /// The FAT table for looking up cluster chains. /// private readonly Fat32Table m_fatTable; /// /// The partition for reading cluster data. /// private readonly IPartition m_partition; /// /// Maps each allocated cluster to the modification time of the file that owns it. /// private readonly Dictionary m_clusterToTimestampMap; /// /// Initializes a new instance of the class and walks the directory tree. /// /// The partition containing the FAT32 filesystem. /// The parsed boot sector containing FAT32 geometry. /// The FAT table for cluster chain lookups. /// Cancellation token. /// Thrown if any parameter is null. public Fat32DirectoryWalker(IPartition partition, Fat32BootSector bootSector, Fat32Table fatTable, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(partition); ArgumentNullException.ThrowIfNull(fatTable); m_partition = partition; m_bootSector = bootSector; m_fatTable = fatTable; m_clusterToTimestampMap = []; // Walk the directory tree starting from the root cluster WalkDirectoryTree(bootSector.RootCluster, DateTime.UnixEpoch, cancellationToken); } /// /// Initializes a new instance of the class for testing. /// This constructor allows direct specification of cluster data without reading from a partition. /// /// The parsed boot sector containing FAT32 geometry. /// The FAT table for cluster chain lookups. /// A function that returns cluster data for a given cluster number. /// Thrown if any parameter is null. internal Fat32DirectoryWalker(Fat32BootSector bootSector, Fat32Table fatTable, Func clusterData) { ArgumentNullException.ThrowIfNull(fatTable); ArgumentNullException.ThrowIfNull(clusterData); m_partition = null!; m_bootSector = bootSector; m_fatTable = fatTable; m_clusterToTimestampMap = []; // Walk the directory tree starting from the root cluster using the provided cluster data WalkDirectoryTreeWithData(bootSector.RootCluster, DateTime.UnixEpoch, clusterData); } /// /// Gets the cluster-to-timestamp map. Each allocated cluster is mapped to the modification time /// of the file or directory that owns it. /// public IReadOnlyDictionary ClusterToTimestampMap => m_clusterToTimestampMap; /// /// Recursively walks the directory tree starting from the specified cluster. /// /// The starting cluster number (root or subdirectory). /// The modification timestamp of the parent directory. /// Cancellation token. private void WalkDirectoryTree(uint startCluster, DateTime directoryTimestamp, CancellationToken cancellationToken) { using var stream = m_partition.OpenReadAsync(cancellationToken).Result; byte[] clusterData(uint clusterNum) { var data = new byte[m_bootSector.ClusterSize]; var offset = m_bootSector.ClusterToByteOffset(clusterNum); stream.Seek(offset, SeekOrigin.Begin); stream.ReadExactly(data); return data; } WalkDirectoryTreeWithData(startCluster, directoryTimestamp, clusterData); } /// /// Recursively walks the directory tree using provided cluster data. /// /// The starting cluster number (root or subdirectory). /// The modification timestamp of the parent directory. /// Function to get cluster data. private void WalkDirectoryTreeWithData(uint startCluster, DateTime directoryTimestamp, Func clusterData) { // Get the cluster chain for this directory var clusterChain = m_fatTable.GetClusterChain(startCluster); foreach (var cluster in clusterChain) { // Mark the directory's own cluster with its timestamp if (!m_clusterToTimestampMap.ContainsKey(cluster)) { m_clusterToTimestampMap[cluster] = directoryTimestamp; } // Read and process the directory entries in this cluster var data = clusterData(cluster); ProcessDirectoryEntries(data, directoryTimestamp, clusterData); } } /// /// Processes directory entries from raw cluster data. /// /// The raw cluster data containing directory entries. /// The modification timestamp of the parent directory. /// Function to get cluster data for subdirectories. private void ProcessDirectoryEntries(byte[] data, DateTime directoryTimestamp, Func clusterData) { var entryCount = data.Length / DIRECTORY_ENTRY_SIZE; for (int i = 0; i < entryCount; i++) { var entryOffset = i * DIRECTORY_ENTRY_SIZE; var entrySpan = data.AsSpan(entryOffset, DIRECTORY_ENTRY_SIZE); // Check for end of directory marker if (entrySpan[0] == END_OF_DIRECTORY_MARKER) break; // Skip deleted entries if (entrySpan[0] == DELETED_ENTRY_MARKER) continue; // Get the attribute byte var attributes = entrySpan[ENTRY_ATTRIBUTES_OFFSET]; // Skip long filename entries if (attributes == ATTR_LONG_FILENAME) continue; // Skip volume labels if ((attributes & ATTR_VOLUME_ID) == ATTR_VOLUME_ID) continue; // Check if this is a directory var isDirectory = (attributes & ATTR_DIRECTORY) == ATTR_DIRECTORY; // Extract the starting cluster number var clusterHigh = BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_CLUSTER_HIGH_OFFSET, 2)); var clusterLow = BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_CLUSTER_LOW_OFFSET, 2)); var startCluster = (uint)((clusterHigh << 16) | clusterLow); // Skip entries with invalid cluster numbers (0 means unallocated for files) if (startCluster < 2) continue; // Extract the filename to check for . and .. entries var filename = GetShortFilename(entrySpan); // Skip . and .. entries to avoid infinite recursion if (filename == "." || filename == "..") continue; // Extract the last write timestamp var lastWriteTime = ParseDateTime( BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_WRITE_DATE_OFFSET, 2)), BinaryPrimitives.ReadUInt16LittleEndian(entrySpan.Slice(ENTRY_WRITE_TIME_OFFSET, 2))); // Use the file's own timestamp, or fall back to the directory's timestamp var fileTimestamp = lastWriteTime > DateTime.UnixEpoch ? lastWriteTime : directoryTimestamp; if (isDirectory) { // Recursively process subdirectories (only if the cluster is valid) if (startCluster < m_fatTable.TotalClusters) { WalkDirectoryTreeWithData(startCluster, fileTimestamp, clusterData); } } else { // For files, map all clusters in the file's chain to the file's timestamp try { // Only process if the start cluster is within valid range if (startCluster >= m_fatTable.TotalClusters) continue; var fileClusters = m_fatTable.GetClusterChain(startCluster); foreach (var fileCluster in fileClusters) { // For overlapping clusters, use the most recent timestamp if (!m_clusterToTimestampMap.TryGetValue(fileCluster, out var existingTimestamp) || fileTimestamp > existingTimestamp) { m_clusterToTimestampMap[fileCluster] = fileTimestamp; } } } catch (InvalidOperationException) { // If we can't get the cluster chain (e.g., circular chain), skip this file // but still mark the starting cluster if it's valid if (startCluster < m_fatTable.TotalClusters) { if (!m_clusterToTimestampMap.TryGetValue(startCluster, out var existingTimestamp) || fileTimestamp > existingTimestamp) { m_clusterToTimestampMap[startCluster] = fileTimestamp; } } } } } } /// /// Gets the short filename (8.3 format) from a directory entry. /// /// The directory entry span. /// The filename string. private static string GetShortFilename(ReadOnlySpan entry) { // Short filename is 11 bytes: 8 for name, 3 for extension var name = entry.Slice(0, 8); var ext = entry.Slice(8, 3); // Trim spaces and construct the filename var nameStr = System.Text.Encoding.ASCII.GetString(name).TrimEnd(); var extStr = System.Text.Encoding.ASCII.GetString(ext).TrimEnd(); if (string.IsNullOrEmpty(extStr)) return nameStr; return $"{nameStr}.{extStr}"; } /// /// Parses FAT32 date and time fields into a DateTime. /// /// The date field (bits 15-9 = year-1980, bits 8-5 = month, bits 4-0 = day). /// The time field (bits 15-11 = hours, bits 10-5 = minutes, bits 4-0 = seconds/2). /// The parsed DateTime in UTC. internal static DateTime ParseDateTime(ushort date, ushort time) { // If both date and time are 0, return Unix epoch if (date == 0 && time == 0) return DateTime.UnixEpoch; // Extract date components var year = 1980 + ((date >> 9) & 0x7F); var month = (date >> 5) & 0x0F; var day = date & 0x1F; // Extract time components var hour = (time >> 11) & 0x1F; var minute = (time >> 5) & 0x3F; var second = (time & 0x1F) * 2; // Validate the values if (year < 1980 || year > 2107) return DateTime.UnixEpoch; if (month < 1 || month > 12) return DateTime.UnixEpoch; if (day < 1 || day > 31) return DateTime.UnixEpoch; if (hour > 23) return DateTime.UnixEpoch; if (minute > 59) return DateTime.UnixEpoch; if (second > 59) return DateTime.UnixEpoch; try { // Create DateTime in UTC (FAT32 doesn't store timezone info) var localTime = new DateTime((int)year, (int)month, (int)day, (int)hour, (int)minute, (int)second, DateTimeKind.Utc); return localTime; } catch (ArgumentOutOfRangeException) { return DateTime.UnixEpoch; } } }