Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/DeterministicPdf/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@

global using System.Diagnostics.CodeAnalysis;
global using DeterministicPdf;
37 changes: 32 additions & 5 deletions src/DeterministicPdf/PdfNormalizer_Streams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ public static partial class PdfNormalizer
// positioned at 0 and ready to read.

/// <summary>
/// Reads <paramref name="source"/> from its current position to the end and returns a normalized copy.
/// Reads <paramref name="source"/> from its current position to the end and returns a normalized
/// copy, as a new <see cref="MemoryStream"/> positioned at 0.
/// </summary>
/// <remarks>
/// The current position is honored for every stream type, including <see cref="MemoryStream"/>.
/// </remarks>
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)));
Expand All @@ -18,11 +22,15 @@ public static MemoryStream Normalize(Stream source) =>
public static async Task<MemoryStream> 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();
Expand All @@ -32,13 +40,32 @@ static byte[] ToBytes(Stream source)

static async Task<byte[]> 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;
}
}
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;CS0649;CA1416;NU1608;NU1109;NU1510</NoWarn>
<Version>1.0.1</Version>
<Version>1.0.2</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<Description>Modify PDF files to ensure they are deterministic. Helpful for testing, build reproducibility, security verification, and ensuring output integrity across different build environments.</Description>
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageVersion Include="System.Buffers" Version="4.6.1" />
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="Verify" Version="31.27.0" />
<PackageVersion Include="TUnit" Version="1.61.29" />
<PackageVersion Include="Microsoft.Sbom.Targets" Version="4.1.5" />
</ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions src/Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
global using System.Text;
global using DeterministicPdf;
global using Docnet.Core;
global using VerifyTests;
61 changes: 61 additions & 0 deletions src/Tests/PdfNormalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
1 change: 1 addition & 0 deletions src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="MarkdownSnippets.MsBuild" PrivateAssets="all" />
<PackageReference Include="TUnit" />
<PackageReference Include="Verify" />
<!-- Test-only: renders the normalized output back to prove it is still a loadable document. -->
<PackageReference Include="Docnet.Core" />
<ProjectReference Include="..\DeterministicPdf\DeterministicPdf.csproj" />
Expand Down
Loading