diff --git a/Library/DiscUtils.Ntfs/NtfsJournal.cs b/Library/DiscUtils.Ntfs/NtfsJournal.cs index 212146419..83f3719a0 100644 --- a/Library/DiscUtils.Ntfs/NtfsJournal.cs +++ b/Library/DiscUtils.Ntfs/NtfsJournal.cs @@ -128,7 +128,7 @@ internal NtfsJournalInfo(NtfsJournalStatus status, string detail) public IReadOnlyList FileRecordsChanged { get; internal set; } = []; /// - /// Gets a value indicating whether replay wrote to the volume, reset the journal and completed its flush calls. + /// Gets a value indicating whether replay wrote to the volume stream, reset the journal and completed its flush calls. /// This is also true for a reset with no metadata changes (). /// public bool Replayed { get; internal set; } @@ -177,8 +177,10 @@ public static class NtfsJournal /// Replay prepares the changes before writing. If the MFT mirror changes, it is written and flushed before /// the primary records. Replay flushes metadata before resetting the restart pages. It writes and flushes /// the alternate restart page before replacing and flushing the source page. - /// A uses Flush(true). For other streams, durability and ordering after a crash - /// depend on whether reaches all backing storage layers. + /// Replay requests with true through a non-owning sparse + /// stream adapter. Built-in wrappers forward the request to backing streams that support disk flushing; + /// other streams retain their parameterless behavior. The caller must establish + /// whether every storage layer honors the barrier. /// Replay is not atomic. An I/O error during the writes or flushes can leave a partially updated volume. /// Journals that require metadata undo or fail the local hibernation check cannot be written by this API. /// @@ -355,6 +357,10 @@ private static NtfsJournalInfo Run(Stream volume, bool write, out Dictionary cluster) => mirrorClusterCount > 0 && cluster.Key >= layout.MftMirrorCluster && cluster.Key - layout.MftMirrorCluster < mirrorClusterCount; @@ -362,20 +368,20 @@ bool IsMirrorCluster(KeyValuePair cluster) => mirrorClusterCount > { // A retry can skip primary records whose LSN already advanced. Their copies must be written first. WriteClusters(volume, clusters.Where(IsMirrorCluster), clusterSize); - FlushReplayWrites(volume); + flushStream.Flush(flushToDisk: true); } WriteClusters(volume, clusters.Where(cluster => !IsMirrorCluster(cluster)), clusterSize); - // Flush even when no pages changed. A previous attempt may have left visible but buffered metadata. - FlushReplayWrites(volume); + // Flush even without page changes. Metadata or a recognized alternate reset may still be buffered. + flushStream.Flush(flushToDisk: true); if (alternateRestart != null) { storage.Write(alternateOffset, alternateRestart, 0, alternateRestart.Length); - FlushReplayWrites(volume); + flushStream.Flush(flushToDisk: true); } storage.Write(sourceOffset, sourceRestart, 0, sourceRestart.Length); - FlushReplayWrites(volume); + flushStream.Flush(flushToDisk: true); info.Replayed = true; } @@ -407,18 +413,6 @@ private static SnapshotStream CreateReplayOverlay(Stream volume, Dictionary> clusters, int clusterSize) { foreach (var cluster in clusters.OrderBy(c => c.Key)) @@ -433,9 +427,12 @@ private static void WriteClusters(Stream stream, IEnumerable private static long AddMftMirror(Stream volume, JournalVolumeLayout layout, Dictionary clusters) { - var mirrorBytes = 4 * layout.MftRecordSize; + const int reservedRecords = 16; + var minimumMirrorBytes = 4 * layout.MftRecordSize; var clusterSize = layout.BytesPerCluster; - var mirrorClusters = (mirrorBytes + (long)clusterSize - 1) / clusterSize; + // A large cluster can mirror additional reserved records. Leave the remaining cluster bytes as stored. + var reservedMirrorBytes = Math.Max(minimumMirrorBytes, Math.Min(clusterSize, reservedRecords * layout.MftRecordSize)); + var mirrorClusters = (reservedMirrorBytes + (long)clusterSize - 1) / clusterSize; var touched = clusters.Keys.Any(lcn => lcn >= layout.MftCluster && lcn - layout.MftCluster < mirrorClusters); if (!touched) { @@ -450,13 +447,13 @@ private static long AddMftMirror(Stream volume, JournalVolumeLayout layout, Dict } var mftStart = layout.MftCluster * clusterSize; - var bytes = new byte[mirrorBytes]; - for (var done = 0; done < mirrorBytes;) + var bytes = new byte[reservedMirrorBytes]; + for (var done = 0; done < bytes.Length;) { var position = mftStart + done; var lcn = position / clusterSize; var inCluster = (int)(position % clusterSize); - var chunk = Math.Min(mirrorBytes - done, clusterSize - inCluster); + var chunk = Math.Min(bytes.Length - done, clusterSize - inCluster); if (clusters.TryGetValue(lcn, out var cluster)) { Array.Copy(cluster, inCluster, bytes, done, chunk); @@ -478,11 +475,12 @@ private static long AddMftMirror(Stream volume, JournalVolumeLayout layout, Dict } var (extents, length) = LogFileStorage.ReadDataAttribute(mirrorRecord, layout, "$MFTMirr"); - if (length < mirrorBytes || extents[0].Lcn != layout.MftMirrorCluster || extents[0].Length < mirrorClusters) + if (length < minimumMirrorBytes || extents[0].Lcn != layout.MftMirrorCluster || extents[0].Length < mirrorClusters) { throw new JournalException("$MFTMirr data does not match the contiguous mirror location in the boot sector"); } + var mirrorBytes = (int)Math.Min(reservedMirrorBytes, length - length % layout.MftRecordSize); var mirrorStart = layout.MftMirrorCluster * clusterSize; for (var recordOffset = 0; recordOffset < mirrorBytes; recordOffset += mirrorRecord.Length) { diff --git a/Library/DiscUtils.Streams/Block/BlockCacheStream.cs b/Library/DiscUtils.Streams/Block/BlockCacheStream.cs index 13a6e8ffc..7419b1717 100644 --- a/Library/DiscUtils.Streams/Block/BlockCacheStream.cs +++ b/Library/DiscUtils.Streams/Block/BlockCacheStream.cs @@ -571,6 +571,16 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + CheckDisposed(); + + if (CanWrite) + { + _wrappedStream.Flush(flushToDisk); + } + } + /// /// Moves the stream position. /// @@ -814,4 +824,4 @@ private void InvalidateBlocks(long firstBlock, int numBlocks) _cache.ReleaseBlock(i); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/ConcatStream.cs b/Library/DiscUtils.Streams/ConcatStream.cs index 685be13b5..08734fcc4 100644 --- a/Library/DiscUtils.Streams/ConcatStream.cs +++ b/Library/DiscUtils.Streams/ConcatStream.cs @@ -135,6 +135,15 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + CheckDisposed(); + for (var i = 0; i < _streams.Count; ++i) + { + _streams[i].Flush(flushToDisk); + } + } + public override int Read(byte[] buffer, int offset, int count) { CheckDisposed(); @@ -396,4 +405,4 @@ private void CheckDisposed() } #endif } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/MirrorStream.cs b/Library/DiscUtils.Streams/MirrorStream.cs index cce756c8b..0803f9397 100644 --- a/Library/DiscUtils.Streams/MirrorStream.cs +++ b/Library/DiscUtils.Streams/MirrorStream.cs @@ -98,6 +98,14 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + foreach (var stream in _wrapped) + { + stream.Flush(flushToDisk); + } + } + public override int Read(byte[] buffer, int offset, int count) { return _wrapped[0].Read(buffer, offset, count); @@ -240,4 +248,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/SnapshotStream.cs b/Library/DiscUtils.Streams/SnapshotStream.cs index a0ba3c9ee..f7bd2a08b 100644 --- a/Library/DiscUtils.Streams/SnapshotStream.cs +++ b/Library/DiscUtils.Streams/SnapshotStream.cs @@ -253,6 +253,13 @@ public override void Flush() _baseStream.Flush(); } + public override void Flush(bool flushToDisk) + { + CheckFrozen(); + + FlushWrappedStream(_baseStream, flushToDisk); + } + /// /// Flushes the stream. /// @@ -622,4 +629,4 @@ private void CheckFrozen() throw new InvalidOperationException("The stream is frozen"); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/SparseStream.cs b/Library/DiscUtils.Streams/SparseStream.cs index f529cf712..4e4bd8e08 100644 --- a/Library/DiscUtils.Streams/SparseStream.cs +++ b/Library/DiscUtils.Streams/SparseStream.cs @@ -48,6 +48,33 @@ public abstract class SparseStream : CompatibilityStream /// This may be an empty enumeration if all bytes are zero. public abstract IEnumerable Extents { get; } + /// + /// Flushes pending changes, requesting synchronization with backing storage when supported. + /// + /// Whether to request a disk flush from supporting backing streams. + /// + /// The default implementation calls . A stream without a stronger flush + /// contract cannot guarantee that its data has reached durable storage. + /// + public virtual void Flush(bool flushToDisk) => Flush(); + + /// Forwards a flush without bypassing intervening sparse-stream wrappers. + protected static void FlushWrappedStream(Stream wrapped, bool flushToDisk) + { + if (wrapped is SparseStream sparse) + { + sparse.Flush(flushToDisk); + } + else if (wrapped is FileStream file) + { + file.Flush(flushToDisk); + } + else + { + wrapped.Flush(); + } + } + /// /// Converts any stream into a sparse stream. /// @@ -483,6 +510,16 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + CheckDisposed(); + + if (CanWrite) + { + FlushWrappedStream(_wrapped, flushToDisk); + } + } + public override int Read(byte[] buffer, int offset, int count) { CheckDisposed(); @@ -688,6 +725,20 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + _sync.Wait(); + + try + { + content.Flush(flushToDisk); + } + finally + { + _sync.Release(); + } + } + public override async Task FlushAsync(CancellationToken cancellationToken) { await _sync.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -1043,4 +1094,4 @@ public override async ValueTask DisposeAsync() public override string ToString() => $"Syncrhonized[{content}]"; } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/StripedStream.cs b/Library/DiscUtils.Streams/StripedStream.cs index 1eff64483..6a9971256 100644 --- a/Library/DiscUtils.Streams/StripedStream.cs +++ b/Library/DiscUtils.Streams/StripedStream.cs @@ -116,6 +116,14 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + foreach (var stream in _wrapped) + { + stream.Flush(flushToDisk); + } + } + public override int Read(byte[] buffer, int offset, int count) { if (!CanRead) @@ -357,4 +365,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/SubStream.cs b/Library/DiscUtils.Streams/SubStream.cs index 4e9dda86b..91abaadde 100644 --- a/Library/DiscUtils.Streams/SubStream.cs +++ b/Library/DiscUtils.Streams/SubStream.cs @@ -109,6 +109,11 @@ public override void Flush() Parent.Flush(); } + public override void Flush(bool flushToDisk) + { + FlushWrappedStream(Parent, flushToDisk); + } + public override int Read(byte[] buffer, int offset, int count) { if (count < 0) @@ -285,4 +290,4 @@ private IEnumerable OffsetExtents(IEnumerable src) yield return new StreamExtent(e.Start - _first, e.Length); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/ThreadSafeStream.cs b/Library/DiscUtils.Streams/ThreadSafeStream.cs index 3dc301183..30d4951f6 100644 --- a/Library/DiscUtils.Streams/ThreadSafeStream.cs +++ b/Library/DiscUtils.Streams/ThreadSafeStream.cs @@ -239,6 +239,14 @@ public override void Flush() } } + public override void Flush(bool flushToDisk) + { + lock (_common) + { + Wrapped.Flush(flushToDisk); + } + } + /// /// Reads data from the stream. /// @@ -398,4 +406,4 @@ public void Dispose() #endregion } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/WrappingMappedStream.cs b/Library/DiscUtils.Streams/WrappingMappedStream.cs index 09f52e89e..fd481adc1 100644 --- a/Library/DiscUtils.Streams/WrappingMappedStream.cs +++ b/Library/DiscUtils.Streams/WrappingMappedStream.cs @@ -114,6 +114,11 @@ public override void Flush() WrappedStream.Flush(); } + public override void Flush(bool flushToDisk) + { + FlushWrappedStream(WrappedStream, flushToDisk); + } + public override Task FlushAsync(CancellationToken cancellationToken) => WrappedStream.FlushAsync(cancellationToken); @@ -205,4 +210,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Streams/WrappingStream.cs b/Library/DiscUtils.Streams/WrappingStream.cs index 7104f8599..ba13b7092 100644 --- a/Library/DiscUtils.Streams/WrappingStream.cs +++ b/Library/DiscUtils.Streams/WrappingStream.cs @@ -77,6 +77,11 @@ public override void Flush() _wrapped.Flush(); } + public override void Flush(bool flushToDisk) + { + _wrapped.Flush(flushToDisk); + } + public override int Read(byte[] buffer, int offset, int count) { return _wrapped.Read(buffer, offset, count); @@ -161,4 +166,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Vhd/DynamicStream.cs b/Library/DiscUtils.Vhd/DynamicStream.cs index c6b3017a5..8c42bcb9b 100644 --- a/Library/DiscUtils.Vhd/DynamicStream.cs +++ b/Library/DiscUtils.Vhd/DynamicStream.cs @@ -150,6 +150,19 @@ public override long Position public override void Flush() { CheckDisposed(); + if (CanWrite) + { + _fileStream.Flush(); + } + } + + public override void Flush(bool flushToDisk) + { + CheckDisposed(); + if (CanWrite) + { + FlushWrappedStream(_fileStream, flushToDisk); + } } public override IEnumerable MapContent(long start, long length) @@ -1236,4 +1249,4 @@ private async ValueTask UpdateFooterAsync(CancellationToken cancellationToken) await _fileStream.WriteAsync(_footerCache, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Library/DiscUtils.Vhdx/ContentStream.cs b/Library/DiscUtils.Vhdx/ContentStream.cs index f2bd91043..71760c3fc 100644 --- a/Library/DiscUtils.Vhdx/ContentStream.cs +++ b/Library/DiscUtils.Vhdx/ContentStream.cs @@ -116,6 +116,18 @@ public override void Flush() _fileStream.Flush(); } + public override void Flush(bool flushToDisk) + { + CheckDisposed(); + + if (!CanWrite) + { + throw new InvalidOperationException("Attempt to flush to read-only VHDX"); + } + + _fileStream.Flush(flushToDisk); + } + public override IEnumerable MapContent(long start, long length) { CheckDisposed(); @@ -650,4 +662,4 @@ private void CheckDisposed() } #endif } -} \ No newline at end of file +} diff --git a/Tests/LibraryTests/Ntfs/Journal/NtfsJournalInterruptionTest.cs b/Tests/LibraryTests/Ntfs/Journal/NtfsJournalInterruptionTest.cs index b43690ef0..fcfb86c4e 100644 --- a/Tests/LibraryTests/Ntfs/Journal/NtfsJournalInterruptionTest.cs +++ b/Tests/LibraryTests/Ntfs/Journal/NtfsJournalInterruptionTest.cs @@ -16,22 +16,28 @@ public partial class NtfsJournalEdgeCaseTest [Theory] [InlineData(1, 0, false, NtfsJournalStatus.NeedsReplay)] [InlineData(1, 512, false, NtfsJournalStatus.NeedsReplay)] + [InlineData(1, 4096, false, NtfsJournalStatus.NeedsReplay)] [InlineData(2, 0, false, NtfsJournalStatus.NeedsReplay)] [InlineData(2, 0, true, NtfsJournalStatus.NeedsReplay)] [InlineData(3, 0, false, NtfsJournalStatus.NeedsReplay)] [InlineData(3, 512, false, NtfsJournalStatus.Unsupported)] [InlineData(3, 4096, false, NtfsJournalStatus.NeedsReplay)] + [InlineData(4, 0, false, NtfsJournalStatus.NeedsReplay)] [InlineData(4, 512, false, NtfsJournalStatus.NeedsReplay)] + [InlineData(4, 4096, false, NtfsJournalStatus.NeedsReplay)] [InlineData(5, 0, false, NtfsJournalStatus.NeedsReplay)] [InlineData(5, 0, true, NtfsJournalStatus.NothingToReplay)] + [InlineData(6, 0, false, NtfsJournalStatus.NothingToReplay)] [InlineData(6, 512, false, NtfsJournalStatus.NothingToReplay)] [InlineData(6, 4096, false, NtfsJournalStatus.NothingToReplay)] [InlineData(7, 0, false, NtfsJournalStatus.NothingToReplay)] [InlineData(7, 0, true, NtfsJournalStatus.NothingToReplay)] [InlineData(8, 0, false, NtfsJournalStatus.NothingToReplay)] [InlineData(8, 512, false, NtfsJournalStatus.Clean)] + [InlineData(8, 4096, false, NtfsJournalStatus.Clean)] [InlineData(9, 0, false, NtfsJournalStatus.NothingToReplay)] [InlineData(9, 0, true, NtfsJournalStatus.Clean)] + [InlineData(0, 0, false, NtfsJournalStatus.Clean)] // No fault. Reopen after the final successful barrier. public void RedoRetryUsesThePersistedImageAtEachWriteAndBarrier(int cut, int persistedPrefix, bool completedBarrier, NtfsJournalStatus expected) { @@ -39,10 +45,45 @@ public void RedoRetryUsesThePersistedImageAtEachWriteAndBarrier(int cut, int per var (writer, rawLcn, updateLsn, attribute) = WriteRedoInterruptionFixture(dirty); var mft = writer.Layout.MftCluster * 4096; var mirror = writer.Layout.MftMirrorCluster * 4096; + using var fs = new NtfsFileSystem(dirty, NtfsJournalMode.Ignore); + var logStart = fs.PathToClusters("$LogFile").First().Offset * 4096; + var writes = new[] + { + (Step: 1, Barrier: 2, Position: mirror), + (Step: 3, Barrier: 5, Position: mft), + (Step: 4, Barrier: 5, Position: rawLcn * 4096), + (Step: 6, Barrier: 7, Position: logStart + 4096), + (Step: 8, Barrier: 9, Position: logStart), + }; - using var device = new CrashImageStream(dirty.ToArray(), cut, persistedPrefix, completedBarrier); - Assert.Same(device.Failure, Assert.Throws(() => NtfsJournal.Replay(device))); - using var retry = JournalScenario.Copy(device.CrashImage); + var initial = dirty.ToArray(); + using var device = new CrashImageStream(initial, cut, persistedPrefix, completedBarrier); + if (cut == 0) + { + Assert.True(NtfsJournal.Replay(device).Replayed); + } + else + { + Assert.Same(device.Failure, Assert.Throws(() => NtfsJournal.Replay(device))); + } + + // Each write is for a different cluster. Check the exact stable image, including untouched bytes. + var events = new (long Position, int Count)[9]; // Default entry denotes a flush. + using var completed = JournalScenario.Copy(initial); + Assert.True(NtfsJournal.Replay(completed).Replayed); + var expectedImage = (byte[])initial.Clone(); + foreach (var (step, barrier, position) in writes) + { + events[step - 1] = (position, 4096); + var count = cut == 0 || barrier < cut || (barrier == cut && completedBarrier) + ? 4096 : step == cut ? persistedPrefix : 0; + Array.Copy(completed.GetBuffer(), position, expectedImage, position, count); + } + + Assert.Equal(events.Take(cut == 0 ? events.Length : cut), device.Events); + var crashImage = device.CrashImage; + Assert.True(expectedImage.AsSpan().SequenceEqual(crashImage), $"Unexpected stable bytes at event {cut}"); + using var retry = JournalScenario.Copy(crashImage); var analysis = NtfsJournal.Analyze(retry); Assert.True(analysis.Status == expected, $"cut {cut}: {analysis}"); @@ -79,6 +120,41 @@ public void RedoRetryUsesThePersistedImageAtEachWriteAndBarrier(int cut, int per Assert.Equal(bytes, retry.ToArray()); } + [Theory] + [InlineData(7)] + [InlineData(9)] + public void RetryOnTheSameStreamAfterAFailedRestartBarrier(int cut) + { + using var dirty = Volume(); + var (writer, _, _, _) = WriteRedoInterruptionFixture(dirty); + using var device = new CrashImageStream(dirty.ToArray(), cut); + Assert.Same(device.Failure, Assert.Throws(() => NtfsJournal.Replay(device))); + + using var stable = JournalScenario.Copy(device.CrashImage); + Assert.Equal(NtfsJournalStatus.NothingToReplay, NtfsJournal.Analyze(stable).Status); + var result = NtfsJournal.Replay(device); + if (cut == 7) + { + // The visible alternate may be buffered. The unconditional barrier runs before replacing the source. + Assert.True(result.Replayed); + using var fs = new NtfsFileSystem(dirty, NtfsJournalMode.Ignore); + var logStart = fs.PathToClusters("$LogFile").First().Offset * 4096; + Assert.Equal(new[] { (0L, 0), (logStart, 4096), (0L, 0) }, device.Events.Skip(cut)); + using var reopened = JournalScenario.Copy(device.CrashImage); + var storage = LogFileStorage.Open(reopened, writer.Layout); + Assert.True(LfsRestartPage.TryRead(storage, 0).IsClean); + Assert.True(LfsRestartPage.TryRead(storage, TestJournalWriter.PageSize).IsClean); + } + else + { + // Both pages are visible, so replay is a no-op. The stable alternate already protects the journal. + Assert.Equal(NtfsJournalStatus.Clean, result.Status); + Assert.False(result.Replayed); + Assert.Equal(cut, device.Events.Count); + Assert.True(NtfsJournal.Replay(stable).Replayed); + } + } + [Fact] public void AnotherInterruptionCanLeaveOneValidCleanRestartPage() { @@ -103,18 +179,39 @@ public void AnotherInterruptionCanLeaveOneValidCleanRestartPage() Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Replay(retry).Status); } - [Fact] - public void APartialResetWithMissingMetadataIsRefused() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AFlushThatDropsMetadataCanPublishAnUnsafeReset(bool publishBoth) { using var dirty = Volume(); var (_, rawLcn, _, _) = WriteRedoInterruptionFixture(dirty); - using var device = new CrashImageStream(dirty.ToArray(), cut: 7, completedBarrier: true); - Assert.Same(device.Failure, Assert.Throws(() => NtfsJournal.Replay(device))); + var rawPosition = rawLcn * 4096; + using var device = new CrashImageStream(dirty.ToArray(), cut: publishBoth ? 0 : 7, completedBarrier: true, + persistWrite: position => position != rawPosition); + if (publishBoth) + { + Assert.True(NtfsJournal.Replay(device).Replayed); + } + else + { + Assert.Same(device.Failure, Assert.Throws(() => NtfsJournal.Replay(device))); + } + using var retry = JournalScenario.Copy(device.CrashImage); - // Inject an image that an ordered metadata barrier could not have produced. - retry.Position = rawLcn * 4096; - retry.WriteByte(0); - AssertReplayRefused(retry, "partial $LogFile reset disagrees"); + Assert.Equal(Read(dirty, rawPosition, 4096), Read(retry, rawPosition, 4096)); + if (publishBoth) + { + // After publishing both resets, no dirty restart page remains to identify the lost metadata. + Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Analyze(retry).Status); + var before = retry.ToArray(); + Assert.False(NtfsJournal.Replay(retry).Replayed); + Assert.Equal(before, retry.ToArray()); + } + else + { + AssertReplayRefused(retry, "partial $LogFile reset disagrees"); + } } [Theory] @@ -246,25 +343,30 @@ private sealed class CrashImageStream : MemoryStream private readonly int _cut; private readonly int _persistedPrefix; private readonly bool _completedBarrier; - private int _events; + private readonly Func? _persistWrite; - public CrashImageStream(byte[] image, int cut, int persistedPrefix = 0, bool completedBarrier = false) + public CrashImageStream(byte[] image, int cut, int persistedPrefix = 0, bool completedBarrier = false, + Func? persistWrite = null) : base((byte[])image.Clone(), 0, image.Length, writable: true, publiclyVisible: true) { _stable = (byte[])image.Clone(); _cut = cut; _persistedPrefix = persistedPrefix; _completedBarrier = completedBarrier; + _persistWrite = persistWrite; } public IOException Failure { get; } = new("Injected interruption"); + public List<(long Position, int Count)> Events { get; } = []; + public byte[] CrashImage => (byte[])_stable.Clone(); public override void Write(byte[] buffer, int offset, int count) { var position = checked((int)Position); - if (++_events == _cut) + Events.Add((position, count)); + if (Events.Count == _cut) { Array.Copy(buffer, offset, _stable, position, Math.Min(count, _persistedPrefix)); throw Failure; @@ -276,13 +378,17 @@ public override void Write(byte[] buffer, int offset, int count) public override void Flush() { - var cut = ++_events == _cut; + Events.Add(default); + var cut = Events.Count == _cut; if (!cut || _completedBarrier) { var live = GetBuffer(); foreach (var (position, count) in _pending) { - Array.Copy(live, position, _stable, position, count); + if (_persistWrite == null || _persistWrite(position)) + { + Array.Copy(live, position, _stable, position, count); + } } _pending.Clear(); diff --git a/Tests/LibraryTests/Ntfs/Journal/NtfsJournalSafetyTest.cs b/Tests/LibraryTests/Ntfs/Journal/NtfsJournalSafetyTest.cs index dec9f251d..0b3a29079 100644 --- a/Tests/LibraryTests/Ntfs/Journal/NtfsJournalSafetyTest.cs +++ b/Tests/LibraryTests/Ntfs/Journal/NtfsJournalSafetyTest.cs @@ -4,6 +4,7 @@ using System.Linq; using DiscUtils.Ntfs; using DiscUtils.Ntfs.Journal; +using DiscUtils.Streams; using File = System.IO.File; using static DiscUtils.Ntfs.Journal.JournalUtilities; @@ -487,6 +488,129 @@ public void FileStreamReplay_RequestsDiskFlushesAtEveryBoundary() } } + [Theory] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(1, true)] + public void PartitionReplayForwardsDiskFlushThroughSparseWrappers(int ranges, bool derivedFile) + { + using var dirty = WithRecords(Forget()); + var path = Path.GetTempFileName(); + try + { + const int padding = 512; + using (var destination = File.OpenWrite(path)) + { + destination.Write(new byte[padding * ranges], 0, padding * ranges); + dirty.WriteTo(destination); + } + + using var file = derivedFile ? new ReplayTrackingFile(path) : new FileStream(path, FileMode.Open, FileAccess.ReadWrite); + using var partition = new SubStream(file, padding, file.Length - padding); + using var nested = ranges == 2 ? new SubStream(partition, padding, dirty.Length) : null; + Stream volume = nested ?? partition; + Assert.True(NtfsJournal.Replay(volume).Replayed); + if (file is ReplayTrackingFile tracking) + { + Assert.Equal(new[] { true, true, true }, tracking.DiskFlushes); + Assert.Equal(0, tracking.StreamFlushes); + } + + Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Analyze(volume).Status); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ReplayUsesAnOverriddenSparseStreamDiskFlush() + { + using var dirty = WithRecords(Forget()); + using var partition = new TrackingSubStream(dirty); + using var volume = new SubStream(partition, 0, partition.Length); + Assert.True(NtfsJournal.Replay(volume).Replayed); + Assert.Equal(3, partition.Flushes); + Assert.Equal(new[] { true, true, true }, partition.DiskFlushes); + } + + [Fact] + public void SparseStreamDiskFlushFallsBackForAnUnknownBackingStream() + { + using var backing = new ReplayTrackingStream(new byte[512], failFlush: int.MaxValue); + using var wrapped = SparseStream.FromStream(backing, Ownership.None); + wrapped.Flush(flushToDisk: true); + Assert.Equal(new[] { "flush" }, backing.Events); + } + + [Fact] + public void SparseStreamDiskFlushPropagatesThroughSynchronizationAndCacheWrappers() + { + using var backing = new MemoryStream(new byte[512], writable: true); + using var tracked = new TrackingSubStream(backing); + using var wrapped = new WrappingStream(tracked, Ownership.None); + using var synchronized = SparseStream.Synchronized(wrapped, Ownership.None); + using var threadSafe = new ThreadSafeStream(synchronized, Ownership.None); + using var cache = new BlockCacheStream(threadSafe, Ownership.None); + + cache.Flush(flushToDisk: true); + Assert.Equal(new[] { true }, tracked.DiskFlushes); + } + + [Fact] + public void DynamicVhdReplayPropagatesFlushAndStopsBeforePublishingAResetOnFailure() + { + using var dirty = Volume(); + var (writer, _, _, _) = WriteRedoInterruptionFixture(dirty); + using var backing = new MemoryStream(); + using (var disk = DiscUtils.Vhd.Disk.InitializeDynamic(backing, Ownership.None, dirty.Length)) + { + dirty.WriteTo(disk.Content); + } + + using var tracked = new ReplayTrackingStream(backing.ToArray(), failFlush: 2); + using var reopened = new DiscUtils.Vhd.Disk(tracked, Ownership.None); + using var volume = new SubStream(reopened.Content, 0, dirty.Length); + var originalRestart = new byte[2 * TestJournalWriter.PageSize]; + writer.Storage.Read(0, originalRestart, 0, originalRestart.Length); + + Assert.Same(tracked.Failure, Assert.Throws(() => NtfsJournal.Replay(volume))); + Assert.Equal(2, tracked.Events.Count(e => e == "flush")); + var actualRestart = new byte[originalRestart.Length]; + LogFileStorage.Open(volume, writer.Layout).Read(0, actualRestart, 0, actualRestart.Length); + Assert.Equal(originalRestart, actualRestart); + + Assert.True(NtfsJournal.Replay(volume).Replayed); + Assert.Equal(5, tracked.Events.Count(e => e == "flush")); + Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Analyze(volume).Status); + } + + [Fact] + public void DynamicVhdReplayRequestsDiskFlushFromFileBacking() + { + using var dirty = WithRecords(Forget()); + var path = Path.GetTempFileName(); + try + { + using (var destination = new FileStream(path, FileMode.Create, FileAccess.ReadWrite)) + using (var disk = DiscUtils.Vhd.Disk.InitializeDynamic(destination, Ownership.None, dirty.Length)) + { + dirty.WriteTo(disk.Content); + } + + using var file = new ReplayTrackingFile(path); + using var reopened = new DiscUtils.Vhd.Disk(file, Ownership.None); + Assert.True(NtfsJournal.Replay(reopened.Content).Replayed); + Assert.Equal(new[] { true, true, true }, file.DiskFlushes); + Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Analyze(reopened.Content).Status); + } + finally + { + File.Delete(path); + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -510,6 +634,40 @@ public void AFailedMirrorWriteOrFlush_LeavesThePrimaryRecordAvailableForRetry(bo Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Analyze(retry).Status); } + [Fact] + public void LargeClusterReplayFlushesTheMirrorOfMftRecordFourBeforeWritingThePrimary() + { + // Use the Windows fixture's geometry and mirror layout, with a synthetic journal update to $AttrDef. + using var dirty = NtfsJournalWindowsSampleTest.LoadVolume("s64k"); + var writer = new TestJournalWriter(dirty, sequence: 1000); + var layout = writer.Layout; + var recordOffset = 4 * layout.MftRecordSize; + var mft = layout.MftCluster * layout.BytesPerCluster; + var mirror = layout.MftMirrorCluster * layout.BytesPerCluster; + var original = Read(dirty, mft + recordOffset, layout.MftRecordSize); + var reservedBytes = 16 * layout.MftRecordSize; + var mirrorTail = Read(dirty, mirror + reservedBytes, layout.BytesPerCluster - reservedBytes); + Assert.False(mirrorTail.AsSpan().SequenceEqual(Read(dirty, mft + reservedBytes, mirrorTail.Length))); + var change = FirstMftValueUpdate(writer, original, Pattern.AsSpan(0, 8).ToArray(), new byte[8]); + change.ClusterBlockOffset = recordOffset / 512; + var restart = writer.Checkpoint(Attributes); + var lsn = writer.Append(change); + writer.Append(Forget()); + writer.Finish(restart); + + using var volume = new ReplayTrackingStream(dirty.ToArray(), failFlush: 1); + Assert.Same(volume.Failure, Assert.Throws(() => NtfsJournal.Replay(volume))); + Assert.Equal(new[] { $"write {mirror}", "flush" }, volume.Events); + Assert.Equal(original, Read(volume, mft + recordOffset, layout.MftRecordSize)); + Assert.Equal(lsn, U64(Read(volume, mirror + recordOffset, layout.MftRecordSize), 8)); + + Assert.True(NtfsJournal.Replay(volume).Replayed); + Assert.Equal(Read(volume, mft + recordOffset, layout.MftRecordSize), + Read(volume, mirror + recordOffset, layout.MftRecordSize)); + Assert.Equal(mirrorTail, Read(volume, mirror + reservedBytes, mirrorTail.Length)); + Assert.Equal(NtfsJournalStatus.Clean, NtfsJournal.Analyze(volume).Status); + } + private sealed class ReplayTrackingStream(byte[] image, int failFlush, long? failWrite = null) : MemoryStream(image, writable: true) { private int _flushes; @@ -543,10 +701,37 @@ private sealed class ReplayTrackingFile(string path) : FileStream(path, FileMode { public List DiskFlushes { get; } = []; + public int StreamFlushes { get; private set; } + + public override void Flush() + { + StreamFlushes++; + base.Flush(); + } + public override void Flush(bool flushToDisk) { DiskFlushes.Add(flushToDisk); base.Flush(flushToDisk); } } + + private sealed class TrackingSubStream(Stream parent) : SubStream(parent, 0, parent.Length) + { + public int Flushes { get; private set; } + + public List DiskFlushes { get; } = []; + + public override void Flush() + { + Flushes++; + base.Flush(); + } + + public override void Flush(bool flushToDisk) + { + DiskFlushes.Add(flushToDisk); + Flush(); + } + } } diff --git a/docs/ntfs-journal-recovery-qualification.md b/docs/ntfs-journal-recovery-qualification.md index b54ccead5..f7b00f51d 100644 --- a/docs/ntfs-journal-recovery-qualification.md +++ b/docs/ntfs-journal-recovery-qualification.md @@ -16,7 +16,7 @@ Tests use a live byte view and a separate persisted image. A completed barrier t Replay stages all writes, flushes mirror changes before the primary MFT, and flushes metadata before resetting either restart page. It publishes and flushes the alternate restart page before overwriting the dirty source. A recognizable clean reset paired with its dirty source is completed only if reanalysis agrees with the reset LSN and finds no pending metadata changes or undo. A torn second write can leave a single valid clean restart page and reduced redundancy. Equal-LSN copies that conflict about the need for recovery or the active checkpoint are refused unless they form a matching reset pair. -This model does not establish crash atomicity. A real backend must honor the barriers and the tested sector-write assumptions. A direct `FileStream` requests disk flushes. Wrapper streams can buffer or reorder writes across storage layers. Recovery cannot detect every inconsistent image produced by a backend that persisted a clean reset ahead of metadata. [FileStream.Flush documentation](https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.flush?view=net-10.0) +This model does not establish crash atomicity. A real backend must honor the barriers and the tested sector-write assumptions. Recovery cannot detect every inconsistent image produced by a backend that persisted a clean reset ahead of metadata. See [persistent replay and storage durability](ntfs-journal-replay-durability.md) for the flush behavior of supported stream configurations. ## Bounded Windows VM qualification diff --git a/docs/ntfs-journal-replay-durability.md b/docs/ntfs-journal-replay-durability.md new file mode 100644 index 000000000..aacac5853 --- /dev/null +++ b/docs/ntfs-journal-replay-durability.md @@ -0,0 +1,44 @@ +# NTFS persistent replay and storage durability + +`NtfsJournal.Replay` and explicit persistent replay on filesystem open use the same write path. Replay is not atomic. An interrupted call can leave partially updated metadata, and an exception can occur after bytes become visible or reach storage. Use an offline volume with exclusive access. The caller must establish the guarantees of the supplied stream and its backing storage. `Replayed` reports writes, a journal reset and successful returns from the flush calls on that stream. + +## Write sequence + +| Phase | Ordering dependency | Retry after an interruption | +| --- | --- | --- | +| Analyze and prepare | Validate admission, stage the metadata, and prepare the required restart pages before writing. The existing undo, client-slot, mapping and hibernation checks apply. | An unsupported state leaves the volume unchanged. | +| Write and flush `$MFTMirr` if needed | The corresponding primary MFT record can advance its page LSN only after the mirror barrier. | An unchanged primary can still be redone after a partial mirror write or a failed mirror flush. | +| Write and flush primary metadata | Every metadata update must pass the barrier before publishing a clean restart page. Flush even when there are no new metadata writes to drain any bytes left buffered by a previous attempt. | A valid protected page can skip an already-applied operation using its LSN. A torn page must have a complete supported replacement or replay refuses it. Supported raw updates can be applied again in log order. | +| Write and flush the alternate restart page | Keep the dirty source available until the alternate reset passes the barrier. | A torn alternate can be discarded if the dirty source remains valid. If the two pages form a recognized partial reset, replay rechecks the log end LSN and requires no pending metadata changes or undo. The preceding unconditional flush also covers a visible but buffered alternate on a retry through the same stream. | +| Write and flush the dirty source restart page | The alternate has already passed its barrier. | A torn source can leave one valid clean restart page and reduced redundancy. A failed final flush can leave two clean pages visible on the live stream. That retry is a clean no-op, while reopening from storage may still find a partial reset. | + +The order is the same for either source restart-page offset. Once a write or flush throws, replay issues no later writes. A backend can still retain part or all of the failed operation, or propagate buffered writes after the call returns. + +The first four MFT records are mirrored on ordinary layouts. The Windows `s64k` fixture has a 64 KiB `$MFTMirr` with matching reserved records beyond the fourth record and differing bytes later in the cluster. Replay copies complete declared records in the reserved range that fits in the mirror and leaves the remaining bytes as stored. Microsoft's NTFS developer notes reserve the first 16 records for special files. NTFS-3G sizes the mirror from the cluster geometry and limits its match check to 16 records based on observed Windows behavior. This evidence establishes a supported prefix for replay. It leaves the mirroring policy of later records across Windows versions unqualified. [Microsoft MFT notes](https://learn.microsoft.com/en-us/windows/win32/devnotes/master-file-table), [NTFS-3G mirror geometry](https://github.com/tuxera/ntfs-3g/blob/edge/libntfs-3g/bootsect.c), [NTFS-3G match check](https://github.com/tuxera/ntfs-3g/blob/edge/libntfs-3g/volume.c) + +## What a flush establishes + +A successful `Write` means the stream accepted the bytes. A subsequent read can see those bytes while they still reside in a managed buffer, an overlay or an OS cache. A wrapper can forward its pending data and flush to the next layer. An OS file synchronization call requests a further boundary. Power-loss survival depends on the filesystem, storage server or hypervisor, controller, media and their cache and write behavior. + +| Supplied stream or configuration | Flush behavior in replay | Established behavior and remaining limits | +| --- | --- | --- | +| Direct `FileStream` | A non-owning `SparseStream.FromStream` adapter requests `Flush(true)` | Requests OS file synchronization. The runtime, filesystem and storage must honor the request and report errors. This call does not establish sector atomicity or protection from every device failure. | +| Partition ranges and fixed VHD content | `SubStream.Flush(true)` forwards to its parent, including nested sparse wrappers | A backing `FileStream` receives `Flush(true)`; custom sparse wrappers can implement their own virtual `Flush(bool)` contract. | +| `SparseStream.FromStream`, synchronization, cache and other forwarding wrappers | Their `Flush(bool)` implementations forward the request through the wrapped stream | A wrapped `FileStream` receives `Flush(true)` and a wrapped `SparseStream` receives its virtual overload. Unknown `Stream` implementations receive `Flush()`. | +| Dynamic or differencing VHD content | Writable `DynamicStream.Flush(true)` forwards to its image file stream | Requests a backing disk flush when supported, after payload and current allocation writes have been issued. Deferred footers and container allocation crash behavior require separate qualification. | +| Writable VHDX content | `ContentStream.Flush(true)` forwards through its sparse image backing stream | This reaches the backing stream on the writable path, subject to any further wrapping. NTFS replay gains no guarantee about container-level crash recovery from that call alone. | +| Memory, a live snapshot, or a custom seekable stream | Virtual `SparseStream.Flush(bool)` defaults to `Flush()` if not overridden | A `MemoryStream` provides visibility in memory. An active `SnapshotStream` keeps overlay writes separate even when it flushes its base. Other implementations require an independently established contract. | + +`NtfsJournal.Replay` continues to accept `Stream`; it creates a non-owning `SparseStream` adapter for the flush boundaries only. There is no journal-specific inspection of a nested `FileStream`. An implementation that overrides the parameterless `Flush()` but also derives from a built-in forwarding wrapper should override `Flush(bool)` if it must intercept the stronger request. + +Modern `FileStream.Flush()` dispatches with `flushToDisk: false`. On Windows the disk-flush path uses `FlushFileBuffers`, including on Microsoft .NET Framework. On Unix, modern .NET requests file synchronization using the runtime's native implementation. Linux `fsync` waits for the device's reported completion. macOS distinguishes `fsync` from `F_FULLFSYNC`, which requests that the device flush its cache. The exact Unix primitive and error handling depend on the runtime version and platform. A .NET Standard build inherits the behavior of its host runtime. The .NET Framework-targeted tests run on Mono locally and check API dispatch, with native Windows coverage from CI. [FileStream API](https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.flush), [modern FileStream source](https://source.dot.net/System.Private.CoreLib/src/runtime/src/libraries/System.Private.CoreLib/src/System/IO/FileStream.cs.html), [.NET Framework source](https://github.com/microsoft/referencesource/blob/main/mscorlib/system/io/filestream.cs), [OS synchronization APIs](https://learn.microsoft.com/en-us/dotnet/api/system.io.randomaccess.flushtodisk), [Linux fsync](https://man7.org/linux/man-pages/man2/fsync.2.html), [Apple fsync](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/fsync.2.html) + +There is also a runtime error-reporting limitation. With .NET 10.0.0 on Linux, a probe against a disposable file observed `fsync` for `Flush(true)` and no `fsync` for `Flush()`. A replay probe on a copy of the `s4kn` fixture behind two built-in `SubStream` ranges reached `fsync` at all three reset boundaries. Injecting `EIO` into those calls still let replay return `Replayed = true`. This reproduces the upstream Unix `SystemNative_FSync` issue and tests error reporting. Qualify the deployed runtime and filesystem when relying on error propagation. [Runtime issue and syscall-injection reproduction](https://github.com/dotnet/runtime/issues/135201), [upstream correction](https://github.com/dotnet/runtime/pull/124725) + +## Evidence and remaining work + +The interruption tests track a live byte image separately from a simulated stable image. They check the issued operations and exact stable bytes at each boundary, including partial writes, a flush that throws before or after advancing the stable image, and reopening after the final successful flush. They cover same-stream retry, retry from the simulated stable image, and repeat recovery on a clean journal. Separate tests verify disk-flush dispatch for file and partition streams, propagation through sparse wrappers and dynamic VHD content, and fallback to parameterless flush for unknown streams. The five Windows crash-image comparisons exercise NTFS replay results. The extra large-cluster mirror update is synthetic and uses the geometry of one of those images. + +The model can also acknowledge a flush while discarding one metadata write. A recognizable partial reset with missing metadata is refused without further mutation. If both clean pages survive while that update is lost, reopening reports a clean journal and cannot diagnose the lost update. The tests establish these behaviors for controlled writes and barriers. Actual power loss can also damage previously flushed data or both restart pages, or interrupt a container allocation update. Those cases require qualification on the deployed storage stack. + +Keep the stream API and existing admission policy for this change. Before relying on power-loss durability for wrapped or remote storage, qualify the runtime, image container, filesystem and device using disposable images. The new overload requests a stronger flush where available but is not an end-to-end guarantee: custom wrappers and external storage layers must implement and honor the request. If an explicit contract is needed, a replay-specific option can accept a caller-provided barrier that covers the whole storage stack after each stream flush, propagates failure, and is invoked before any dependent write.