diff --git a/DomainTests/ZlibLimitTests.swift b/DomainTests/ZlibLimitTests.swift new file mode 100644 index 00000000..a9862e5f --- /dev/null +++ b/DomainTests/ZlibLimitTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing + +@testable import VitrineDomain + +@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(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: length) == original) + #expect(throws: Zlib.ZlibError.outputTooLarge) { + try Zlib.decompress(compressed, maxOutputBytes: length - 1) + } + } + + @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(Self.emptyStream, maxOutputBytes: limit) + } + } +} diff --git a/VitrineDomain/Models/ShareLinkCoding.swift b/VitrineDomain/Models/ShareLinkCoding.swift index 2d23ff71..8643e0a5 100644 --- a/VitrineDomain/Models/ShareLinkCoding.swift +++ b/VitrineDomain/Models/ShareLinkCoding.swift @@ -38,7 +38,12 @@ public enum Zlib { stream.deallocate() } - let bufferSize = min(64 * 1024, max(maxOutputBytes + 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() }