From a70420eefb6a0ed6001b31760db99cab5204ceca Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Wed, 7 Oct 2026 12:36:40 -0500 Subject: [PATCH 1/2] fix(domain): avoid overflow in zlib output limits --- DomainTests/ZlibLimitTests.swift | 34 ++++++++++++++++++++++ VitrineDomain/Models/ShareLinkCoding.swift | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 DomainTests/ZlibLimitTests.swift diff --git a/DomainTests/ZlibLimitTests.swift b/DomainTests/ZlibLimitTests.swift new file mode 100644 index 00000000..0e7bdb69 --- /dev/null +++ b/DomainTests/ZlibLimitTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing + +@testable import VitrineDomain + +@Suite("Zlib output limits") +struct ZlibLimitTests { + @Test func maximumIntegerLimitDoesNotOverflow() throws { + let original = Data("small payload".utf8) + let compressed = try Zlib.compress(original) + #expect(try Zlib.decompress(compressed, maxOutputBytes: Int.max) == original) + } + + @Test func exactAndInsufficientLimitsAcrossChunkBoundary() throws { + let original = Data(repeating: 65, count: 64 * 1024 + 1) + let compressed = try Zlib.compress(original) + #expect(try Zlib.decompress(compressed, maxOutputBytes: original.count) == original) + #expect(throws: Zlib.ZlibError.outputTooLarge) { + try Zlib.decompress(compressed, maxOutputBytes: original.count - 1) + } + } + + @Test func zeroAndNegativeLimitsKeepTheirErrorContract() throws { + let empty = try Zlib.compress(Data()) + #expect(try Zlib.decompress(empty, maxOutputBytes: 0).isEmpty) + let nonempty = try Zlib.compress(Data([65])) + #expect(throws: Zlib.ZlibError.outputTooLarge) { + try Zlib.decompress(nonempty, maxOutputBytes: 0) + } + #expect(throws: Zlib.ZlibError.decompressionFailed) { + try Zlib.decompress(nonempty, maxOutputBytes: -1) + } + } +} diff --git a/VitrineDomain/Models/ShareLinkCoding.swift b/VitrineDomain/Models/ShareLinkCoding.swift index 2d23ff71..db10660f 100644 --- a/VitrineDomain/Models/ShareLinkCoding.swift +++ b/VitrineDomain/Models/ShareLinkCoding.swift @@ -38,7 +38,8 @@ public enum Zlib { stream.deallocate() } - let bufferSize = min(64 * 1024, max(maxOutputBytes + 1, 1)) + // Clamp before adding the sentinel byte so Int.max remains a valid ceiling. + let bufferSize = min(maxOutputBytes, 64 * 1024 - 1) + 1 let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) defer { destination.deallocate() } From d949319357a1232154a0de6dd266ec4261e9e376 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:08:16 +0000 Subject: [PATCH 2/2] fix(domain): keep the zlib chunk capacity at least one byte for any limit - Clamp the output limit to zero before sizing the chunk so a negative limit can never reach UnsafeMutablePointer.allocate with a negative capacity, while Int.max still avoids the sentinel-byte overflow. - Build the empty raw DEFLATE stream by hand, cover -1, -2, and Int.min, and pin the 64 KiB boundary at 65535, 65536, and 65537 bytes. --- DomainTests/ZlibLimitTests.swift | 28 +++++++++++++++------- VitrineDomain/Models/ShareLinkCoding.swift | 8 +++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/DomainTests/ZlibLimitTests.swift b/DomainTests/ZlibLimitTests.swift index 0e7bdb69..a9862e5f 100644 --- a/DomainTests/ZlibLimitTests.swift +++ b/DomainTests/ZlibLimitTests.swift @@ -5,30 +5,42 @@ import Testing @Suite("Zlib output limits") struct ZlibLimitTests { + /// A raw DEFLATE stream holding nothing: one empty, final, fixed-Huffman block. Built + /// by hand so the test does not depend on how `NSData` compresses empty input. + private static let emptyStream = Data([0x03, 0x00]) + @Test func maximumIntegerLimitDoesNotOverflow() throws { let original = Data("small payload".utf8) let compressed = try Zlib.compress(original) #expect(try Zlib.decompress(compressed, maxOutputBytes: Int.max) == original) } - @Test func exactAndInsufficientLimitsAcrossChunkBoundary() throws { - let original = Data(repeating: 65, count: 64 * 1024 + 1) + @Test(arguments: [64 * 1024 - 1, 64 * 1024, 64 * 1024 + 1]) + func exactAndInsufficientLimitsAroundTheChunkBoundary(length: Int) throws { + let original = Data(repeating: 65, count: length) let compressed = try Zlib.compress(original) - #expect(try Zlib.decompress(compressed, maxOutputBytes: original.count) == original) + #expect(try Zlib.decompress(compressed, maxOutputBytes: length) == original) #expect(throws: Zlib.ZlibError.outputTooLarge) { - try Zlib.decompress(compressed, maxOutputBytes: original.count - 1) + try Zlib.decompress(compressed, maxOutputBytes: length - 1) } } - @Test func zeroAndNegativeLimitsKeepTheirErrorContract() throws { - let empty = try Zlib.compress(Data()) - #expect(try Zlib.decompress(empty, maxOutputBytes: 0).isEmpty) + @Test func zeroLimitAcceptsOnlyAnEmptyStream() throws { + #expect(try Zlib.decompress(Self.emptyStream, maxOutputBytes: 0).isEmpty) let nonempty = try Zlib.compress(Data([65])) #expect(throws: Zlib.ZlibError.outputTooLarge) { try Zlib.decompress(nonempty, maxOutputBytes: 0) } + } + + @Test(arguments: [-1, -2, Int.min]) + func negativeLimitsFailClosedWithoutTrapping(limit: Int) throws { + let nonempty = try Zlib.compress(Data([65])) + #expect(throws: Zlib.ZlibError.decompressionFailed) { + try Zlib.decompress(nonempty, maxOutputBytes: limit) + } #expect(throws: Zlib.ZlibError.decompressionFailed) { - try Zlib.decompress(nonempty, maxOutputBytes: -1) + try Zlib.decompress(Self.emptyStream, maxOutputBytes: limit) } } } diff --git a/VitrineDomain/Models/ShareLinkCoding.swift b/VitrineDomain/Models/ShareLinkCoding.swift index db10660f..8643e0a5 100644 --- a/VitrineDomain/Models/ShareLinkCoding.swift +++ b/VitrineDomain/Models/ShareLinkCoding.swift @@ -38,8 +38,12 @@ public enum Zlib { stream.deallocate() } - // Clamp before adding the sentinel byte so Int.max remains a valid ceiling. - let bufferSize = min(maxOutputBytes, 64 * 1024 - 1) + 1 + // Chunk size: the limit plus one spare byte, capped at 64 KiB. Clamping before the + // `+ 1` keeps `Int.max` a valid ceiling, and the lower bound keeps the capacity at + // least 1 even if the guard above ever changes (a negative capacity traps). For a + // limit below the cap, the spare byte exposes an overrun inside the same chunk; + // above it, the overrun surfaces on the next iteration instead. + let bufferSize = min(max(maxOutputBytes, 0), 64 * 1024 - 1) + 1 let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) defer { destination.deallocate() }