From 995103eb5b9e508db3a4813aed64030318a5e7b6 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:06:30 +0000 Subject: [PATCH] fix: emit inclusive range-end in Cryptify Content-Range header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunked Cryptify upload emitted the exclusive end index in the Content-Range header (`bytes {offset}-{end}/*`), but per RFC 9110 §14.4 range-end is inclusive. A first 1 MB chunk produced `bytes 0-1048576/*`, overlapping the next chunk by one byte. Emit `{end - 1}` so the header pins the last byte actually written. Add CryptifyContentRangeTests driving UploadAsync through a fake HttpMessageHandler: one test pins the inclusive end of a single chunk, the other verifies consecutive chunks are contiguous and non-overlapping. Refs #28 Co-Authored-By: Claude Opus 4.8 --- src/Api/CryptifyClient.cs | 5 +- .../CryptifyContentRangeTests.cs | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/E4A.PostGuard.Tests/CryptifyContentRangeTests.cs diff --git a/src/Api/CryptifyClient.cs b/src/Api/CryptifyClient.cs index 31d33e5..d897637 100644 --- a/src/Api/CryptifyClient.cs +++ b/src/Api/CryptifyClient.cs @@ -94,7 +94,10 @@ private async Task StoreChunkAsync( Content = new ByteArrayContent(data, offset, end - offset) }; request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - request.Content.Headers.Add("Content-Range", $"bytes {offset}-{end}/*"); + // `end` is the exclusive end index of the chunk; RFC 9110 §14.4 range-end + // is inclusive, so emit the last byte index (end - 1). `end > offset` + // always holds here (chunkLen >= 1), so this never goes negative. + request.Content.Headers.Add("Content-Range", $"bytes {offset}-{end - 1}/*"); request.Headers.TryAddWithoutValidation("cryptifytoken", token); var response = await _http.SendAsync(request, ct); diff --git a/tests/E4A.PostGuard.Tests/CryptifyContentRangeTests.cs b/tests/E4A.PostGuard.Tests/CryptifyContentRangeTests.cs new file mode 100644 index 0000000..01317a0 --- /dev/null +++ b/tests/E4A.PostGuard.Tests/CryptifyContentRangeTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http.Json; +using E4A.PostGuard.Api; +using E4A.PostGuard.Models; + +namespace E4A.PostGuard.Tests; + +public class CryptifyContentRangeTests +{ + private const int ChunkSize = 1024 * 1024; // mirrors CryptifyClient.ChunkSize + + /// + /// Records the Content-Range header of every chunk PUT and replies with the + /// canned init/chunk/finalize responses the upload flow expects. + /// + private sealed class RecordingHandler : HttpMessageHandler + { + public List ChunkRanges { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + HttpResponseMessage response; + + if (path.EndsWith("/fileupload/init")) + { + response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { uuid = "test-uuid" }) + }; + } + else if (path.Contains("/fileupload/finalize/")) + { + response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("") + }; + } + else // chunk PUT: /fileupload/{uuid} + { + // Read the body so the content stream is fully consumed, then + // capture the Content-Range header for assertions. + _ = await request.Content!.ReadAsByteArrayAsync(cancellationToken); + ChunkRanges.Add(request.Content!.Headers.GetValues("Content-Range").Single()); + response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("") + }; + } + + // Every init/chunk response must carry a rolling cryptifytoken. + response.Headers.Add("cryptifytoken", "tok"); + return response; + } + } + + private static IReadOnlyList Recipient() => + [new RecipientBuilder("alice@example.com", RecipientBaseType.Email)]; + + [Fact] + public async Task StoreChunk_EmitsInclusiveEndByte() + { + var handler = new RecordingHandler(); + using var http = new HttpClient(handler); + var client = new CryptifyClient(http, "https://cryptify.example/"); + + // Single 5-byte chunk: bytes 0..4 inclusive. + await client.UploadAsync(new byte[] { 1, 2, 3, 4, 5 }, Recipient(), null); + + // Not "bytes 0-5/*": RFC 9110 §14.4 range-end is inclusive, so the last + // byte of a 5-byte chunk is index 4. + Assert.Equal("bytes 0-4/*", Assert.Single(handler.ChunkRanges)); + } + + [Fact] + public async Task StoreChunk_ConsecutiveChunksDoNotOverlap() + { + var handler = new RecordingHandler(); + using var http = new HttpClient(handler); + var client = new CryptifyClient(http, "https://cryptify.example/"); + + // ChunkSize + 1 bytes forces a full first chunk plus a 1-byte second chunk. + await client.UploadAsync(new byte[ChunkSize + 1], Recipient(), null); + + Assert.Equal(2, handler.ChunkRanges.Count); + // First chunk: inclusive end is ChunkSize - 1, not ChunkSize. + Assert.Equal($"bytes 0-{ChunkSize - 1}/*", handler.ChunkRanges[0]); + // Second chunk starts at ChunkSize — one past the first chunk's inclusive + // end, so the ranges are contiguous with no overlapping byte. + Assert.Equal($"bytes {ChunkSize}-{ChunkSize}/*", handler.ChunkRanges[1]); + } +}