diff --git a/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs b/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs
new file mode 100644
index 000000000..de479b5ff
--- /dev/null
+++ b/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs
@@ -0,0 +1,938 @@
+// 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;
+
+///
+/// Metadata for NTFS filesystems, storing cluster and block size information.
+///
+public sealed record NtfsFilesystemMetadata
+{
+ ///
+ /// Gets the block size used for reading/writing data.
+ ///
+ public int BlockSize { get; init; }
+
+ ///
+ /// Gets the size of a cluster in bytes.
+ ///
+ public int ClusterSize { get; init; }
+
+ ///
+ /// Gets the total number of clusters in the volume.
+ ///
+ public long TotalClusters { get; init; }
+
+ ///
+ /// Gets the number of allocated (in-use) clusters.
+ ///
+ public long AllocatedClusters { get; init; }
+
+ ///
+ /// Gets the number of free clusters.
+ ///
+ public long FreeClusters { get; init; }
+
+ ///
+ /// Gets the size of an MFT record in bytes.
+ ///
+ public int MftRecordSize { get; init; }
+
+ ///
+ /// Gets a value indicating whether the filesystem has a journal ($LogFile).
+ /// Always true for NTFS.
+ ///
+ public bool HasJournal => true;
+}
+
+///
+/// Represents a file (block) in an NTFS filesystem.
+///
+public class NtfsFile : IFile
+{
+ ///
+ public string? Path { get; init; }
+
+ ///
+ public long? Address { get; init; }
+
+ ///
+ public long Size { get; init; }
+
+ ///
+ public bool IsDirectory => false;
+
+ ///
+ /// Gets the last modification timestamp of the block.
+ /// This is the maximum timestamp of any file whose clusters overlap this block.
+ ///
+ public DateTime? LastModified { get; init; }
+
+ ///
+ /// Gets a value indicating whether the block contains any allocated clusters.
+ ///
+ public bool IsAllocated { get; init; }
+}
+
+///
+/// Represents an NTFS filesystem.
+/// Provides filesystem-aware block-level access with cluster allocation tracking.
+///
+internal class NtfsFilesystem : IFilesystem
+{
+ ///
+ /// Cache of zero buffers keyed by block size.
+ ///
+ private static readonly ConcurrentDictionary s_zeroBuffers = new();
+
+ ///
+ public FileSystemType Type => FileSystemType.NTFS;
+
+ ///
+ public IPartition Partition { get; }
+
+ ///
+ /// Gets the block size used for reading/writing data.
+ ///
+ private readonly int m_blockSize;
+
+ ///
+ /// The parsed NTFS boot sector.
+ ///
+ private readonly NtfsBootSector m_bootSector;
+
+ ///
+ /// The bitmap reader for cluster allocation.
+ ///
+ private readonly NtfsBitmap m_bitmap;
+
+ ///
+ /// The MFT walker that built the cluster-to-timestamp map.
+ ///
+ private readonly NtfsMftWalker m_mftWalker;
+
+ ///
+ /// Maps block index to block metadata (LastModified, IsAllocated).
+ ///
+ private readonly BlockMetadata[] m_blockMetadata;
+
+ ///
+ /// The total number of blocks in the partition.
+ ///
+ private readonly long m_blockCount;
+
+ ///
+ /// Flag to indicate whether the object has been disposed.
+ ///
+ private bool m_disposed;
+
+ ///
+ /// Metadata for a single block.
+ ///
+ private readonly struct BlockMetadata
+ {
+ ///
+ /// The last modification timestamp for this block.
+ ///
+ public DateTime LastModified { get; init; }
+
+ ///
+ /// Whether this block contains any allocated clusters.
+ ///
+ public bool IsAllocated { get; init; }
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The parent partition.
+ /// The block size for reading/writing (default is 1MB).
+ /// Thrown if partition is null.
+ /// Thrown if block size is invalid.
+ 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();
+ }
+
+ ///
+ /// Builds the metadata array for all blocks.
+ ///
+ /// An array of BlockMetadata for each block.
+ 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 (long blockIndex = 0; blockIndex < m_blockCount; blockIndex++)
+ {
+ long blockStart = blockIndex * m_blockSize;
+ long 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
+ DateTime maxTimestamp = DateTime.UnixEpoch;
+ bool hasAllocatedClusters = false;
+
+ // Calculate which clusters are in this block
+ long firstClusterInBlock = blockStart / clusterSize;
+ long lastClusterInBlock = (blockEnd - 1) / clusterSize;
+
+ // Clamp to valid cluster range
+ firstClusterInBlock = Math.Max(0, firstClusterInBlock);
+ lastClusterInBlock = Math.Min(lastClusterInBlock, totalClusters - 1);
+
+ for (long 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;
+ }
+
+ ///
+ /// Tries to detect whether a given partition has an NTFS file system.
+ ///
+ /// The raw disk to check for the filesystem.
+ /// The offset on the disk where the partition starts.
+ /// The size of the partition.
+ /// Cancellation token.
+ /// A task that resolves to true if a valid NTFS filesystem is detected, otherwise false.
+ public static async Task 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;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (m_disposed)
+ return;
+
+ m_disposed = true;
+ }
+
+ ///
+ public Task