diff --git a/CHANGELOG.md b/CHANGELOG.md index 4365c251ff..65716b38ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file. Take a look #### LCP * The CRL used to validate LCP licenses is now checked to be a genuine X.509 CRL before being cached. Networks with a captive portal (e.g. on a plane) could return their login page with a `200 OK` status, which was then cached for seven days and prevented opening LCP publications. An invalid CRL cached by a previous version is now ignored instead of waiting for its expiration. +* [#579](https://github.com/readium/swift-toolkit/issues/579) Streamed LCP audiobooks now start playing almost immediately. Resources encrypted with AES-CBC are decrypted and served in chunks, instead of being fully downloaded and decrypted upfront. ## [4.0.0-alpha.1] - 2026-08-14 diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 86f23cf5b4..d028faf6d7 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -120,8 +120,28 @@ final class LCPDecryptor: Sendable { await plainTextSize() } + /// Number of plaintext bytes decrypted and delivered per `consume` + /// call when streaming. + private static let chunkSize: UInt64 = 256 * 1024 + @concurrent func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { - guard let range = range else { + var plainTextSize: UInt64? + switch await self.plainTextSize() { + case let .success(size): + plainTextSize = size + case let .failure(error): + guard range == nil else { + return .failure(error) + } + } + + guard let plainTextSize else { + // Without the plaintext size, we can't compute the chunks to + // decrypt; fall back on reading and decrypting the whole + // resource in one shot. + guard range == nil else { + return failure(.noPlainTextSize) + } return await license.decryptFully(data: resource.read(), isDeflated: encryption.isDeflated) .map { consume($0) @@ -129,54 +149,97 @@ final class LCPDecryptor: Sendable { } } - return await resource.estimatedLength().asyncFlatMap { encryptedLength in + let requestedRange = range ?? 0 ..< plainTextSize + let clampedRange = min(requestedRange.lowerBound, plainTextSize) ..< min(requestedRange.upperBound, plainTextSize) + guard !clampedRange.isEmpty else { + return .success(()) + } + + return await resource.estimatedLength().asyncFlatMap { [self] encryptedLength in guard let encryptedLength = encryptedLength else { return .failure(.decoding(LCPDecryptor.Error.requiredEstimatedLength)) } - guard let rangeFirst = range.first, let rangeLast = range.last else { - return .failure(.decoding(LCPDecryptor.Error.invalidRange(range))) + + // Decrypting in chunks lets the caller process the beginning + // of the resource without waiting for the whole range, which + // matters when streaming a large track from the network. + var chunkStart = clampedRange.lowerBound + while chunkStart < clampedRange.upperBound { + guard !Task.isCancelled else { + return .failure(.cancelled) + } + + let chunkEnd = min(chunkStart + Self.chunkSize, clampedRange.upperBound) + let result = await decrypt( + range: chunkStart ..< chunkEnd, + encryptedLength: encryptedLength, + plainTextSize: plainTextSize + ) + switch result { + case let .success(chunk): + consume(chunk) + chunkStart = chunkEnd + case let .failure(error): + return .failure(error) + } } - // Encrypted data is shifted by AESBlockSize, because of IV and because the - // previous block must be provided to perform XOR on intermediate blocks. - let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize) - let encryptedEndExclusive = min( - (rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize, - encryptedLength - ) - - return await resource.read(range: encryptedStart ..< encryptedEndExclusive) - .combine(plainTextSize()) - .flatMap { encryptedData, plainTextSize in - do { - guard let plainTextSize = plainTextSize else { - return .failure(.decoding(LCPDecryptor.Error.noPlainTextSize)) - } - guard let bytes = try license.decipher(encryptedData) else { - return .failure(.decoding(LCPDecryptor.Error.emptyDecryptedData)) - } - - // Exclude the bytes added to match a multiple of AESBlockSize. - let sliceStart = (rangeFirst - encryptedStart) - - let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize - let rangeLength = isLastBlockRead - // Use decrypted length to ensure `rangeLast` doesn't exceed decrypted length - 1. - ? min(rangeLast, plainTextSize - 1) - rangeFirst + 1 - // The last block won't be read, so there's no need to compute the length - : rangeLast - rangeFirst + 1 - - // Keep only enough bytes to fit the length-corrected request in order to never - // include padding. - let sliceEnd = sliceStart + rangeLength - - consume(bytes[sliceStart ..< sliceEnd]) - return .success(()) - } catch { - return .failure(.decoding(error)) + return .success(()) + } + } + + /// Decrypts a single chunk of plaintext located at `range`. + private func decrypt( + range: Range, + encryptedLength: UInt64, + plainTextSize: UInt64 + ) async -> ReadResult { + guard let rangeFirst = range.first, let rangeLast = range.last else { + return failure(.invalidRange(range)) + } + + // Encrypted data is shifted by AESBlockSize, because of IV and + // because the previous block must be provided to perform XOR on + // intermediate blocks. + let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize) + let encryptedEndExclusive = min( + (rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize, + encryptedLength + ) + + return await resource.read(range: encryptedStart ..< encryptedEndExclusive) + .flatMap { [self] encryptedData in + do { + guard let bytes = try license.decipher(encryptedData) else { + return failure(.emptyDecryptedData) } + + // Exclude the bytes added to match a multiple of + // AESBlockSize. + let sliceStart = (rangeFirst - encryptedStart) + + let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize + let rangeLength = isLastBlockRead + // Use decrypted length to ensure `rangeLast` + // doesn't exceed decrypted length - 1. + ? min(rangeLast, plainTextSize - 1) - rangeFirst + 1 + // The last block won't be read, so there's no need + // to compute the length + : rangeLast - rangeFirst + 1 + + // Keep only enough bytes to fit the length-corrected + // request in order to never include padding. + let sliceEnd = sliceStart + rangeLength + + return .success(bytes[sliceStart ..< sliceEnd]) + } catch { + return .failure(.decoding(error)) } - } + } + } + + private func failure(_ error: LCPDecryptor.Error) -> ReadResult { + .failure(.decoding(error)) } } } diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 7f268bb7d8..4f2bd3be78 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -156,7 +156,27 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo /// Returns whether the resource is currently playing or not. public var state: MediaPlaybackState { - MediaPlaybackState(player.timeControlStatus) + let state = MediaPlaybackState(player.timeControlStatus) + if + state == .playing, + let item = player.currentItem, + item.isPlaybackBufferEmpty, !item.isPlaybackLikelyToKeepUp + { + // As `automaticallyWaitsToMinimizeStalling` is disabled, the + // player reports `.playing` even when it is stalled on an empty + // buffer waiting for data. + return .loading + } + return state + } + + /// Indicates whether the player is meant to be playing, even if it is + /// currently stalled waiting for data. + /// + /// Unlike `state`, this reflects the playback intent, which is what we + /// need to know when temporarily pausing the player to seek. + private var isPlaybackRequested: Bool { + player.timeControlStatus != .paused } /// Current playback info. @@ -243,7 +263,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo /// Seeks to the given time in the current resource. public func seek(to time: Double) async { - let wasPlaying = (state == .playing) + let wasPlaying = isPlaybackRequested pause() await player.seek(to: CMTime(seconds: time, preferredTimescale: 1000)) @@ -261,10 +281,23 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private var rateObserver: NSKeyValueObservation? private var timeControlStatusObserver: NSKeyValueObservation? private var currentItemObserver: NSKeyValueObservation? + private var itemStatusObserver: NSKeyValueObservation? + private var itemLikelyToKeepUpObserver: NSKeyValueObservation? private var timeObserverToken: TimeObserverToken? private var notificationTask: Task? - private lazy var mediaLoader = PublicationMediaLoader(publication: publication) + private lazy var mediaLoader: PublicationMediaLoader = { + let loader = PublicationMediaLoader(publication: publication) + loader.onLoadingError = { [weak self] href, error in + Task { @MainActor in + guard let self = self, let href = href.relativeURL else { + return + } + self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error) + } + } + return loader + }() private lazy var player: AVPlayer = makePlayer() @@ -312,8 +345,10 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo } } - currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] _, _ in + currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] player, _ in + let item = player.currentItem Task { @MainActor [weak self] in + self?.observe(currentItem: item) self?.playbackDidChange() } } @@ -339,6 +374,33 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo return player } + private func observe(currentItem item: AVPlayerItem?) { + itemLikelyToKeepUpObserver = item?.observe(\.isPlaybackLikelyToKeepUp) { [weak self] _, _ in + Task { @MainActor [weak self] in + self?.playbackDidChange() + } + } + + itemStatusObserver = item?.observe(\.status) { [weak self] item, _ in + guard item.status == .failed else { + return + } + + let itemError = item.error + + Task { @MainActor [weak self] in + guard let self = self else { return } + log(.error, "Failed to load the player item: \(String(describing: itemError))") + guard let href = self.publication.readingOrder[self.resourceIndex].url().relativeURL else { + return + } + let error: ReadError = itemError.flatMap { .wrap($0) } + ?? .decoding("The AVPlayerItem failed to load", cause: itemError) + self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error) + } + } + } + private func shouldPlayNextResource() -> Bool { guard let delegate = delegate else { return true @@ -459,7 +521,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo public private(set) var currentLocation: Locator? public func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool { - let wasPlaying = (state == .playing) + let wasPlaying = isPlaybackRequested pause() guard let newResourceIndex = publication.readingOrder.firstIndexWithHREF(locator.href) else { diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index c36d1a3208..a886f5b4b3 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -19,6 +19,10 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log private let publication: Publication + /// Called when a resource failed to be served to the player, e.g. to + /// forward the error to the `NavigatorDelegate`. + var onLoadingError: ((AnyURL, ReadError) -> Void)? + private let tasks = CancellableTasks() init(publication: Publication) { @@ -63,6 +67,13 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log else { return nil } + + // Only the resources of other entries are evicted, as the player + // routinely abandons its requests to issue new ones for the same + // entry. Dropping the current resource would throw away its buffered + // data and force re-downloading the beginning of the entry. + resources = resources.filter { requests[$0.key] != nil } + resources[href] = (link, resource) return (link, resource) } @@ -99,8 +110,9 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log let req = reqs.remove(at: index) req.task.cancel() + // The resource is intentionally kept in `resources`, to reuse its + // buffered data with the next loading requests for the same entry. if reqs.isEmpty { - resources.removeValue(forKey: href) requests.removeValue(forKey: href) } else { requests[href] = reqs @@ -138,7 +150,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log using resource: Resource, link: Link ) { - tasks.add { + tasks.add { [self] in infoRequest.isByteRangeAccessSupported = true infoRequest.contentType = link.mediaType?.uti @@ -149,6 +161,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log case let .failure(error): log(.error, error) + report(error, forHREF: link.url()) request.finishLoading(with: error) } } @@ -162,17 +175,24 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log range = UInt64(dataRequest.currentOffset) ..< (UInt64(dataRequest.currentOffset) + UInt64(dataRequest.requestedLength)) } - let task = Task { + let task = Task { [self] in let result = await resource.stream( range: range, consume: { dataRequest.respond(with: $0) } ) + // The player abandons data requests regularly, e.g. when seeking. + // There's nothing to report or finish in this case. + guard !Task.isCancelled else { + return + } + queue.async { [weak self] in switch result { case .success: request.finishLoading() case let .failure(error): + self?.report(error, forHREF: link.url()) request.finishLoading(with: error) } @@ -183,6 +203,15 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log registerRequest(request, task: task, for: link.url()) } + private func report(_ error: ReadError, forHREF href: AnyURL) { + // Cancellation is not an error worth reporting, it occurs whenever + // the player abandons a data request, e.g. when seeking. + if case .cancelled = error { + return + } + onLoadingError?(href, error) + } + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { finishRequest(loadingRequest) } diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index 58cfa64379..36f1ae6fe0 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -38,11 +38,25 @@ public enum ReadError: Error, Sendable { .decoding(DebugError(message, cause: cause)) } + /// Converts an ``HTTPError`` into a ``ReadError``. + /// + /// A cancelled request is normalized to ``cancelled``, so that callers + /// don't need to check for cancellation in two different places. + public init(_ error: HTTPError) { + if case .cancelled = error { + self = .cancelled + } else { + self = .access(.http(error)) + } + } + /// Wraps a native error into a `ReadError`, if possible. /// /// Returns `nil` if the error cannot be mapped to a known `ReadError`. public static func wrap(_ error: Error) -> ReadError? { switch error { + case let error as ReadError: + return error case is CancellationError: return .cancelled case let error as CocoaError: @@ -51,10 +65,7 @@ public enum ReadError: Error, Sendable { return wrap(error) default: if let error = HTTPError.wrap(error) { - if case .cancelled = error { - return .cancelled - } - return .access(.http(error)) + return ReadError(error) } else { return nil } diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index 735d8ab54b..aa004063d9 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -74,8 +74,14 @@ public actor BufferingResource: Resource, Loggable { return .success(()) } - // Read ahead from the request start to fill the buffer. - let readAheadEnd = requestedRange.lowerBound + UInt64(buffer.maxSize) + // Read ahead from the request start to fill the buffer, without + // requesting bytes past the end of the resource. Not all resources + // clamp out-of-range requests, e.g. HTTP servers reply with a + // 416 Range Not Satisfiable error. + var readAheadEnd = requestedRange.lowerBound + UInt64(buffer.maxSize) + if case let .success(.some(length)) = await estimatedLength() { + readAheadEnd = min(readAheadEnd, length) + } let readRange = requestedRange.lowerBound ..< max(requestedRange.upperBound, readAheadEnd) // Range that will actually need to be read from the original resource, diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 17f8f70479..31bfbb0627 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -107,6 +107,6 @@ public actor HTTPResource: Resource { } ) .map { _ in () } - .mapError { .access(.http($0)) } + .mapError { ReadError($0) } } } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index b0d70493b5..73b3154bd1 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -86,7 +86,7 @@ public extension HTTPServer { let link = publication.linkWithHREF(href), let resource = publication.get(href) else { - onFailure?(request, .access(.http(notFound))) + onFailure?(request, ReadError(notFound)) return HTTPServerResponse(error: notFound) } @@ -136,7 +136,7 @@ public struct HTTPServerResponse: Sendable { public init(error: HTTPError) { self.init( - resource: FailureResource(error: .access(.http(error))), + resource: FailureResource(error: ReadError(error)), mediaType: nil ) } diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index 7aa3e61397..f2e55c34a1 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -112,6 +112,14 @@ private actor ZIPFoundationResource: Resource, Loggable { return await archive().asyncFlatMap { archive in do { if let range = range { + // The `Streamable` contract requires out-of-range indexes + // to be clamped, while ZIPFoundation throws a + // `rangeOutOfBounds` error. + let length = entry.uncompressedSize + let range = min(range.lowerBound, length) ..< min(range.upperBound, length) + guard !range.isEmpty else { + return .success(()) + } try await archive.extractRange(range, of: entry) { data in try Task.checkCancellation() consume(data) diff --git a/Tests/LCPTests/Capture.swift b/Tests/LCPTests/Capture.swift new file mode 100644 index 0000000000..14042c76ab --- /dev/null +++ b/Tests/LCPTests/Capture.swift @@ -0,0 +1,19 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// A reference-type wrapper that allows a value to be captured and mutated +/// inside a `@Sendable` closure. +/// +/// Warning: Not thread-safe - only for sequential test code. +final nonisolated class Capture: @unchecked Sendable { + var value: T + + init(_ value: T) { + self.value = value + } +} diff --git a/Tests/LCPTests/LCPDecryptionTests.swift b/Tests/LCPTests/LCPDecryptionTests.swift index 3abb935ed4..29c0fb9c8c 100644 --- a/Tests/LCPTests/LCPDecryptionTests.swift +++ b/Tests/LCPTests/LCPDecryptionTests.swift @@ -70,6 +70,41 @@ struct LCPDecryptionTests { } } + /// A large range is decrypted and delivered in several chunks, so the + /// caller can process the beginning of the resource without waiting for + /// the whole range. + @Test func streamsLargeRangeInChunks() async throws { + let chunks = Capture<[Data]>([]) + let result = await encryptedResource.stream(range: 0 ..< UInt64(clearData.count)) { chunk in + chunks.value.append(chunk) + } + try result.get() + + #expect(chunks.value.count > 1) + #expect(chunks.value.reduce(Data(), +) == clearData) + } + + /// Cancelling the task stops the decryption loop, instead of streaming + /// the remaining chunks. + @Test func cancellationStopsStreaming() async { + let chunks = Capture<[Data]>([]) + + // The streaming runs in a child task, as cancelling the test's own + // task would abort the test instead of the decryption loop. + let result = await Task { + await encryptedResource.stream(range: 0 ..< UInt64(clearData.count)) { chunk in + chunks.value.append(chunk) + withUnsafeCurrentTask { $0?.cancel() } + } + }.value + + guard case .failure(.cancelled) = result else { + Issue.record("Expected a cancelled failure, got \(result)") + return + } + #expect(chunks.value.count == 1) + } + /// Reproduces the arithmetic overflow in /// `CBCLCPResource.stream(range:consume:)`. /// diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index b2bcff92b2..a1be4802ff 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -100,6 +100,20 @@ class BufferingResourceTests: XCTestCase { } } + /// The read-ahead must not request bytes past the end of the resource, + /// as some resources reject out-of-range requests instead of clamping + /// them (e.g. HTTP servers reply with a 416 error). + func testReadNearEndDoesNotOverRequest() async throws { + let data = Data((0 ..< 2000).map { UInt8($0 % 256) }) + let sut = BufferingResource(resource: StrictResource(data: data), bufferSize: 1024) + + var result = try await sut.read(range: 1900 ..< 2000).get() + XCTAssertEqual(result, data[1900 ..< 2000]) + + result = try await sut.read(range: 1990 ..< 1995).get() + XCTAssertEqual(result, data[1990 ..< 1995]) + } + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file) @@ -120,3 +134,35 @@ class BufferingResourceTests: XCTestCase { } } } + +/// A `Resource` that fails when requesting a range past its end, instead of +/// clamping it. +private actor StrictResource: Resource { + private let data: Data + + init(data: Data) { + self.data = data + } + + let sourceURL: AbsoluteURL? = nil + + func estimatedLength() async -> ReadResult { + .success(UInt64(data.count)) + } + + func properties() async -> ReadResult { + .success(ResourceProperties()) + } + + func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + guard let range = range else { + consume(data) + return .success(()) + } + guard range.upperBound <= UInt64(data.count) else { + return .failure(.decoding("Requested a range out of bounds")) + } + consume(data[Int(range.lowerBound) ..< Int(range.upperBound)]) + return .success(()) + } +} diff --git a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift index 857e02fa73..8f08616fba 100644 --- a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift @@ -107,6 +107,35 @@ class MinizipContainerTests: XCTestCase { ) } + /// Out-of-range indexes must be clamped per the `Streamable` contract. + func testReadRangeOverlappingEndIsClamped() async throws { + let container = try await container(for: "test.zip") + + let stored = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) + var data = try await stored.read(range: 14 ..< 40).get() + XCTAssertEqual( + String(data: data, encoding: .utf8), + " ZIP.\n" + ) + + let compressed = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) + data = try await compressed.read(range: 29600 ..< 29700).get() + XCTAssertEqual(data.count, 9) + } + + /// Out-of-range indexes must be clamped per the `Streamable` contract. + func testReadRangePastEndReturnsEmptyData() async throws { + let container = try await container(for: "test.zip") + + let stored = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) + var data = try await stored.read(range: 100 ..< 200).get() + XCTAssertEqual(data, Data()) + + let compressed = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) + data = try await compressed.read(range: 40000 ..< 40100).get() + XCTAssertEqual(data, Data()) + } + func testRandomCompressedRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") diff --git a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift index f86e892428..fec4c49ffd 100644 --- a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift @@ -106,6 +106,35 @@ class ZIPFoundationContainerTests: XCTestCase { ) } + /// Out-of-range indexes must be clamped per the `Streamable` contract. + func testReadRangeOverlappingEndIsClamped() async throws { + let container = try await container(for: "test.zip") + + let stored = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) + var data = try await stored.read(range: 14 ..< 40).get() + XCTAssertEqual( + String(data: data, encoding: .utf8), + " ZIP.\n" + ) + + let compressed = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) + data = try await compressed.read(range: 29600 ..< 29700).get() + XCTAssertEqual(data.count, 9) + } + + /// Out-of-range indexes must be clamped per the `Streamable` contract. + func testReadRangePastEndReturnsEmptyData() async throws { + let container = try await container(for: "test.zip") + + let stored = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) + var data = try await stored.read(range: 100 ..< 200).get() + XCTAssertEqual(data, Data()) + + let compressed = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) + data = try await compressed.read(range: 40000 ..< 40100).get() + XCTAssertEqual(data, Data()) + } + func testRandomCompressedRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip")