From 100e14c36bdae7ede607dd2694c4487cb0f10748 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 22 Jul 2026 22:01:05 +1000 Subject: [PATCH] Honor Stream.Position in MemoryStream path Fixes the stream overloads so they always read from the current position to end, including the MemoryStream fast path. Instead of MemoryStream.ToArray() (which ignored Position), the code now copies only the remaining bytes via TryGetBuffer and falls back to CopyTo when the buffer is not exposed. Added regression tests for prefixed input and consistency between MemoryStream/FileStream behavior, plus a patch version bump to 1.0.2 and a test-only Verify dependency for TempFile usage. --- src/DeterministicPdf/GlobalUsings.cs | 3 +- src/DeterministicPdf/PdfNormalizer_Streams.cs | 37 +++++++++-- src/Directory.Build.props | 2 +- src/Directory.Packages.props | 1 + src/Tests/GlobalUsings.cs | 1 + src/Tests/PdfNormalizerTests.cs | 61 +++++++++++++++++++ src/Tests/Tests.csproj | 1 + 7 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/DeterministicPdf/GlobalUsings.cs b/src/DeterministicPdf/GlobalUsings.cs index 8b13789..4ae2381 100644 --- a/src/DeterministicPdf/GlobalUsings.cs +++ b/src/DeterministicPdf/GlobalUsings.cs @@ -1 +1,2 @@ - +global using System.Diagnostics.CodeAnalysis; +global using DeterministicPdf; diff --git a/src/DeterministicPdf/PdfNormalizer_Streams.cs b/src/DeterministicPdf/PdfNormalizer_Streams.cs index 8eed113..4b04a27 100644 --- a/src/DeterministicPdf/PdfNormalizer_Streams.cs +++ b/src/DeterministicPdf/PdfNormalizer_Streams.cs @@ -8,8 +8,12 @@ public static partial class PdfNormalizer // positioned at 0 and ready to read. /// - /// Reads from its current position to the end and returns a normalized copy. + /// Reads from its current position to the end and returns a normalized + /// copy, as a new positioned at 0. /// + /// + /// The current position is honored for every stream type, including . + /// public static MemoryStream Normalize(Stream source) => // The buffer is built here, so it is owned here: patch it in place rather than copying again. new(NormalizeCore(ToBytes(source))); @@ -18,11 +22,15 @@ public static MemoryStream Normalize(Stream source) => public static async Task NormalizeAsync(Stream source, Cancel cancel = default) => new(NormalizeCore(await ToBytesAsync(source, cancel))); + // Both paths read from the current position to the end, so the result never depends on the + // concrete stream type. MemoryStream.ToArray is deliberately not used as the fast path: it + // returns the whole buffer regardless of Position, which would silently disagree with the + // CopyTo fallback for a stream that has already been partly read. static byte[] ToBytes(Stream source) { - if (source is MemoryStream memoryStream) + if (TryCopyRemaining(source, out var bytes)) { - return memoryStream.ToArray(); + return bytes; } using var buffer = new MemoryStream(); @@ -32,13 +40,32 @@ static byte[] ToBytes(Stream source) static async Task ToBytesAsync(Stream source, Cancel cancel) { - if (source is MemoryStream memoryStream) + if (TryCopyRemaining(source, out var bytes)) { - return memoryStream.ToArray(); + return bytes; } using var buffer = new MemoryStream(); await source.CopyToAsync(buffer, cancel); return buffer.ToArray(); } + + // Copies the unread remainder of a MemoryStream directly out of its backing array, avoiding the + // second copy the CopyTo fallback would make. Fails for a MemoryStream constructed to hide its + // buffer, which then takes the fallback. + static bool TryCopyRemaining(Stream source, [NotNullWhen(true)] out byte[]? bytes) + { + if (source is not MemoryStream memoryStream || + !memoryStream.TryGetBuffer(out var segment)) + { + bytes = null; + return false; + } + + var position = (int) memoryStream.Position; + var count = segment.Count - position; + bytes = new byte[count]; + Array.Copy(segment.Array!, segment.Offset + position, bytes, 0, count); + return true; + } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 94bae8b..51eff1b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;CA1416;NU1608;NU1109;NU1510 - 1.0.1 + 1.0.2 preview 1.0.0 Modify PDF files to ensure they are deterministic. Helpful for testing, build reproducibility, security verification, and ensuring output integrity across different build environments. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0ea0585..1984225 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,6 +13,7 @@ + \ No newline at end of file diff --git a/src/Tests/GlobalUsings.cs b/src/Tests/GlobalUsings.cs index ffa4845..8cd26ad 100644 --- a/src/Tests/GlobalUsings.cs +++ b/src/Tests/GlobalUsings.cs @@ -2,3 +2,4 @@ global using System.Text; global using DeterministicPdf; global using Docnet.Core; +global using VerifyTests; diff --git a/src/Tests/PdfNormalizerTests.cs b/src/Tests/PdfNormalizerTests.cs index d48466a..5e3f4e2 100644 --- a/src/Tests/PdfNormalizerTests.cs +++ b/src/Tests/PdfNormalizerTests.cs @@ -207,6 +207,67 @@ public async Task StreamOverloadMatchesByteOverload() await Assert.That(result.ToArray()).IsEquivalentTo(expected); } + [Test] + public async Task StreamOverloadHonorsPosition() + { + // The document is preceded by unrelated bytes and the stream is positioned at the start of + // the document, as it would be when reading from a container. Only the remainder is read. + var data = await File.ReadAllBytesAsync("sample-fop.pdf"); + var expected = PdfNormalizer.Normalize(data); + + var prefix = "unrelated leading bytes"u8.ToArray(); + using var source = new MemoryStream([..prefix, ..data]); + source.Position = prefix.Length; + // ReSharper disable once MethodHasAsyncOverload + using var result = PdfNormalizer.Normalize(source); + + await Assert.That(result.ToArray()).IsEquivalentTo(expected); + } + + [Test] + public async Task StreamOverloadHonorsPositionOnExposedBuffer() + { + // A MemoryStream that exposes its backing array (as produced by `new MemoryStream()` then + // written to, which is how a caller hands over a freshly generated document) takes the + // direct-copy fast path rather than the CopyTo fallback. It must honor Position too. + var data = await File.ReadAllBytesAsync("sample-fop.pdf"); + var expected = PdfNormalizer.Normalize(data); + + var prefix = "unrelated leading bytes"u8.ToArray(); + using var source = new MemoryStream(); + source.Write(prefix, 0, prefix.Length); + source.Write(data, 0, data.Length); + source.Position = prefix.Length; + + // ReSharper disable once MethodHasAsyncOverload + using var result = PdfNormalizer.Normalize(source); + + await Assert.That(result.ToArray()).IsEquivalentTo(expected); + } + + [Test] + public async Task StreamOverloadHonorsPositionConsistentlyAcrossStreamTypes() + { + // A MemoryStream and a FileStream positioned identically must produce the same result. The + // MemoryStream fast path reads the backing array directly, so it has to respect Position + // the same way the copy fallback does. + var data = await File.ReadAllBytesAsync("sample-fop.pdf"); + var prefixed = new byte[8 + data.Length]; + Array.Copy(data, 0, prefixed, 8, data.Length); + + using var temp = await TempFile.CreateBinary(prefixed, ".pdf"); + + using var memorySource = new MemoryStream(prefixed); + memorySource.Position = 8; + using var fromMemory = await PdfNormalizer.NormalizeAsync(memorySource); + + using var fileSource = File.OpenRead(temp.Path); + fileSource.Position = 8; + using var fromFile = await PdfNormalizer.NormalizeAsync(fileSource); + + await Assert.That(fromMemory.ToArray()).IsEquivalentTo(fromFile.ToArray()); + } + [Test] public async Task AsyncProducesSameOutputAsSync() { diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 67fe699..579a41a 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -9,6 +9,7 @@ +