@capgo/cordova-updater 8.1.4 → 8.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/esm/definitions.d.ts +116 -86
  2. package/dist/esm/definitions.js +2 -2
  3. package/dist/esm/definitions.js.map +1 -1
  4. package/dist/esm/exec.js +11 -1
  5. package/dist/esm/exec.js.map +1 -1
  6. package/dist/esm/history.js +7 -7
  7. package/dist/esm/history.js.map +1 -1
  8. package/dist/plugin.cjs.js +13 -3
  9. package/dist/plugin.cjs.js.map +1 -1
  10. package/dist/plugin.js +13 -3
  11. package/dist/plugin.js.map +1 -1
  12. package/package.json +5 -3
  13. package/plugin.xml +9 -9
  14. package/src/android/CordovaUpdaterPlugin.java +173 -5
  15. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
  16. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
  17. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
  18. package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
  19. package/src/android/app/capgo/cordova/updater/Version.java +312 -0
  20. package/src/android/capgo-cordova-updater.gradle +3 -5
  21. package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
  22. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
  23. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
  24. package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
  25. package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
  26. package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
  27. package/src/ios/CapgoSemanticVersion.swift +181 -0
  28. package/src/ios/CapgoUpdater.swift +427 -114
  29. package/src/ios/CordovaUpdaterPlugin.swift +171 -40
  30. package/src/ios/CryptoCipher.swift +41 -38
  31. package/src/ios/DelayUpdateUtils.swift +19 -45
  32. package/src/ios/Logger.swift +81 -5
  33. package/src/ios/RedirectPolicyDelegate.swift +53 -0
  34. package/src/ios/WebViewStatsReporter.swift +5 -0
  35. package/src/ios/ZipArchiveReader.swift +263 -0
  36. package/src/ios/ZipCentralDirectory.swift +283 -0
@@ -0,0 +1,53 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+
9
+ /// URLSession delegate that refuses redirects from HTTPS to plain HTTP unless explicitly allowed.
10
+ /// A refused redirect cancels the task, so callers see a transport error instead of the 3xx body.
11
+ final class RedirectPolicyDelegate: NSObject, URLSessionTaskDelegate {
12
+ private let lock = NSLock()
13
+ private var allowDowngrade = false
14
+ /// Called when a downgrade redirect is refused. Must not retain the updater strongly.
15
+ var onBlockedRedirect: ((URL?, URL?) -> Void)?
16
+
17
+ var allowHttpsToHttpRedirect: Bool {
18
+ get {
19
+ lock.lock()
20
+ defer { lock.unlock() }
21
+ return allowDowngrade
22
+ }
23
+ set {
24
+ lock.lock()
25
+ allowDowngrade = newValue
26
+ lock.unlock()
27
+ }
28
+ }
29
+
30
+ static func isHttpsToHttpDowngrade(from source: URL?, to target: URL?) -> Bool {
31
+ guard let sourceScheme = source?.scheme?.lowercased(), let targetScheme = target?.scheme?.lowercased() else {
32
+ return false
33
+ }
34
+ return sourceScheme == "https" && targetScheme == "http"
35
+ }
36
+
37
+ func urlSession(
38
+ _: URLSession,
39
+ task: URLSessionTask,
40
+ willPerformHTTPRedirection response: HTTPURLResponse,
41
+ newRequest request: URLRequest,
42
+ completionHandler: @escaping (URLRequest?) -> Void
43
+ ) {
44
+ let source = response.url ?? task.currentRequest?.url
45
+ if !allowHttpsToHttpRedirect && Self.isHttpsToHttpDowngrade(from: source, to: request.url) {
46
+ onBlockedRedirect?(source, request.url)
47
+ completionHandler(nil)
48
+ task.cancel()
49
+ return
50
+ }
51
+ completionHandler(request)
52
+ }
53
+ }
@@ -165,6 +165,11 @@ final class WebViewStatsReporter {
165
165
  webView.evaluateJavaScript(Self.script, completionHandler: nil)
166
166
  }
167
167
 
168
+ func reinstallDocumentStartScript(on webView: WKWebView?) {
169
+ installed = false
170
+ install(on: webView)
171
+ }
172
+
168
173
  func reportError(_ call: CAPPluginCall) {
169
174
  let errorType = call.getString("type") ?? "javascript_error"
170
175
  let current = implementation.getCurrentBundle()
@@ -0,0 +1,263 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Compression
8
+ import Foundation
9
+
10
+ enum ZipError: Error, Equatable {
11
+ case unreadableArchive
12
+ case missingEndOfCentralDirectory
13
+ case invalidCentralDirectory
14
+ case invalidLocalFileHeader
15
+ case truncatedEntryData
16
+ case corruptedEntryData
17
+ case encryptedEntry
18
+ case unsupportedCompressionMethod(UInt16)
19
+ case invalidBufferSize
20
+ case entryTooLarge
21
+ }
22
+
23
+ enum ZipEntryType {
24
+ case file
25
+ case directory
26
+ case symlink
27
+ }
28
+
29
+ struct ZipEntry {
30
+ let path: String
31
+ let type: ZipEntryType
32
+ let generalPurposeFlags: UInt16
33
+ let compressionMethod: UInt16
34
+ let compressedSize: UInt64
35
+ let uncompressedSize: UInt64
36
+ let localHeaderOffset: UInt64
37
+
38
+ var isEncrypted: Bool {
39
+ return generalPurposeFlags & 0x0001 != 0
40
+ }
41
+ }
42
+
43
+ /// Small read-only ZIP reader used to extract downloaded bundles.
44
+ ///
45
+ /// Supports stored (0) and deflate (8) entries, ZIP64 archives, and streams entry contents in
46
+ /// `bufferSize` chunks so large files are never fully loaded in memory. Encrypted entries and other
47
+ /// compression methods are rejected. Every offset and size read from the archive is bounds-checked, so a
48
+ /// corrupt archive throws instead of crashing.
49
+ ///
50
+ /// This reader does not validate entry paths. Callers must resolve `ZipEntry.path` against the destination
51
+ /// directory (see `CapgoUpdater.resolvePathInsideDirectory`) before writing anything.
52
+ final class ZipArchiveReader {
53
+ static let compressionMethodStored: UInt16 = 0
54
+ static let compressionMethodDeflate: UInt16 = 8
55
+ private static let localFileHeaderSignature: UInt32 = 0x0403_4b50
56
+ private static let localFileHeaderSize = 30
57
+
58
+ let entries: [ZipEntry]
59
+ private let fileHandle: FileHandle
60
+ private let fileSize: UInt64
61
+ private let centralDirectoryOffset: UInt64
62
+
63
+ init(url: URL) throws {
64
+ let handle: FileHandle
65
+ do {
66
+ handle = try FileHandle(forReadingFrom: url)
67
+ } catch {
68
+ throw ZipError.unreadableArchive
69
+ }
70
+ do {
71
+ let size = try handle.seekToEnd()
72
+ self.fileHandle = handle
73
+ self.fileSize = size
74
+ let archiveIndex = try ZipCentralDirectory.readEntries(handle: handle, fileSize: size)
75
+ self.entries = archiveIndex.entries
76
+ self.centralDirectoryOffset = archiveIndex.centralDirectoryOffset
77
+ } catch {
78
+ try? handle.close()
79
+ throw error
80
+ }
81
+ }
82
+
83
+ deinit {
84
+ try? fileHandle.close()
85
+ }
86
+
87
+ /// Streams the uncompressed contents of `entry` to `consumer` in chunks of at most `bufferSize` bytes.
88
+ /// Directory entries produce no data.
89
+ func extract(_ entry: ZipEntry, bufferSize: Int, consumer: (Data) throws -> Void) throws {
90
+ guard bufferSize > 0 else {
91
+ throw ZipError.invalidBufferSize
92
+ }
93
+ if entry.type == .directory {
94
+ return
95
+ }
96
+ if entry.isEncrypted {
97
+ throw ZipError.encryptedEntry
98
+ }
99
+
100
+ let header = try ZipBytes.readChunk(handle: fileHandle, fileSize: fileSize, at: entry.localHeaderOffset, count: Self.localFileHeaderSize)
101
+ guard ZipBytes.uint32(header, 0) == Self.localFileHeaderSignature else {
102
+ throw ZipError.invalidLocalFileHeader
103
+ }
104
+ let localFlags = ZipBytes.uint16(header, 6)
105
+ let localMethod = ZipBytes.uint16(header, 8)
106
+ let nameLength = UInt64(ZipBytes.uint16(header, 26))
107
+ let extraLength = UInt64(ZipBytes.uint16(header, 28))
108
+ if localFlags & 0x0001 != 0 {
109
+ throw ZipError.encryptedEntry
110
+ }
111
+
112
+ let headerLength = UInt64(Self.localFileHeaderSize) + nameLength + extraLength
113
+ let (dataOffset, overflow) = entry.localHeaderOffset.addingReportingOverflow(headerLength)
114
+ guard !overflow, dataOffset <= fileSize else {
115
+ throw ZipError.invalidLocalFileHeader
116
+ }
117
+
118
+ // Like the previous ZIPFoundation-based extraction, the local header decides the compression method
119
+ // while sizes come from the central directory.
120
+ switch localMethod {
121
+ case Self.compressionMethodStored:
122
+ // A stored entry's payload is its content; a size mismatch would read past it into the next records.
123
+ guard entry.compressedSize == entry.uncompressedSize else {
124
+ throw ZipError.corruptedEntryData
125
+ }
126
+ try readStored(from: dataOffset, size: entry.uncompressedSize, bufferSize: bufferSize, consumer: consumer)
127
+ case Self.compressionMethodDeflate:
128
+ if entry.compressedSize == 0 && entry.uncompressedSize == 0 {
129
+ // Some writers store empty files as deflate with no payload at all.
130
+ return
131
+ }
132
+ try readDeflated(
133
+ from: dataOffset,
134
+ compressedSize: entry.compressedSize,
135
+ expectedSize: entry.uncompressedSize,
136
+ bufferSize: bufferSize,
137
+ consumer: consumer
138
+ )
139
+ default:
140
+ throw ZipError.unsupportedCompressionMethod(localMethod)
141
+ }
142
+ }
143
+
144
+ /// Reads the full contents of a small entry (e.g. a symlink target), refusing entries larger than `maxBytes`.
145
+ func readSmallEntry(_ entry: ZipEntry, maxBytes: Int, bufferSize: Int) throws -> Data {
146
+ var result = Data()
147
+ try extract(entry, bufferSize: bufferSize) { chunk in
148
+ guard result.count + chunk.count <= maxBytes else {
149
+ throw ZipError.entryTooLarge
150
+ }
151
+ result.append(chunk)
152
+ }
153
+ return result
154
+ }
155
+
156
+ static func dataRangeFitsWithin(offset: UInt64, length: UInt64, upperBound: UInt64) -> Bool {
157
+ guard length <= upperBound else {
158
+ return false
159
+ }
160
+ return offset <= upperBound - length
161
+ }
162
+
163
+ private func readStored(from offset: UInt64, size: UInt64, bufferSize: Int, consumer: (Data) throws -> Void) throws {
164
+ guard Self.dataRangeFitsWithin(offset: offset, length: size, upperBound: centralDirectoryOffset) else {
165
+ throw ZipError.corruptedEntryData
166
+ }
167
+ guard size <= fileSize, offset <= fileSize - size else {
168
+ throw ZipError.truncatedEntryData
169
+ }
170
+ try fileHandle.seek(toOffset: offset)
171
+ var remaining = size
172
+ while remaining > 0 {
173
+ let chunkSize = Int(min(UInt64(bufferSize), remaining))
174
+ try autoreleasepool {
175
+ guard let chunk = try fileHandle.read(upToCount: chunkSize), chunk.count == chunkSize else {
176
+ throw ZipError.truncatedEntryData
177
+ }
178
+ try consumer(chunk)
179
+ }
180
+ remaining -= UInt64(chunkSize)
181
+ }
182
+ }
183
+
184
+ /// Inflates an entry, rejecting output that differs from the central directory's uncompressed size.
185
+ /// This also bounds decompression bombs to the declared size.
186
+ private func readDeflated(
187
+ from offset: UInt64,
188
+ compressedSize: UInt64,
189
+ expectedSize: UInt64,
190
+ bufferSize: Int,
191
+ consumer: (Data) throws -> Void
192
+ ) throws {
193
+ guard Self.dataRangeFitsWithin(offset: offset, length: compressedSize, upperBound: centralDirectoryOffset) else {
194
+ throw ZipError.corruptedEntryData
195
+ }
196
+ guard compressedSize <= fileSize, offset <= fileSize - compressedSize else {
197
+ throw ZipError.truncatedEntryData
198
+ }
199
+ try fileHandle.seek(toOffset: offset)
200
+
201
+ let stream = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
202
+ defer { stream.deallocate() }
203
+ // COMPRESSION_ZLIB decodes raw DEFLATE (RFC 1951), which is what ZIP method 8 stores.
204
+ guard compression_stream_init(stream, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB) == COMPRESSION_STATUS_OK else {
205
+ throw ZipError.corruptedEntryData
206
+ }
207
+ defer { compression_stream_destroy(stream) }
208
+
209
+ let sourceBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
210
+ defer { sourceBuffer.deallocate() }
211
+ let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
212
+ defer { destinationBuffer.deallocate() }
213
+
214
+ var remaining = compressedSize
215
+ stream.pointee.src_ptr = UnsafePointer(sourceBuffer)
216
+ stream.pointee.src_size = 0
217
+
218
+ var producedTotal: UInt64 = 0
219
+ var finished = false
220
+ while !finished {
221
+ try autoreleasepool {
222
+ if stream.pointee.src_size == 0 && remaining > 0 {
223
+ let chunkSize = Int(min(UInt64(bufferSize), remaining))
224
+ guard let chunk = try fileHandle.read(upToCount: chunkSize), chunk.count == chunkSize else {
225
+ throw ZipError.truncatedEntryData
226
+ }
227
+ chunk.copyBytes(to: sourceBuffer, count: chunkSize)
228
+ stream.pointee.src_ptr = UnsafePointer(sourceBuffer)
229
+ stream.pointee.src_size = chunkSize
230
+ remaining -= UInt64(chunkSize)
231
+ }
232
+
233
+ stream.pointee.dst_ptr = destinationBuffer
234
+ stream.pointee.dst_size = bufferSize
235
+ let flags = remaining == 0 ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0
236
+ let status = compression_stream_process(stream, flags)
237
+ let produced = bufferSize - stream.pointee.dst_size
238
+ if produced > 0 {
239
+ producedTotal += UInt64(produced)
240
+ guard producedTotal <= expectedSize else {
241
+ throw ZipError.corruptedEntryData
242
+ }
243
+ try consumer(Data(bytes: destinationBuffer, count: produced))
244
+ }
245
+
246
+ switch status {
247
+ case COMPRESSION_STATUS_END:
248
+ guard producedTotal == expectedSize else {
249
+ throw ZipError.corruptedEntryData
250
+ }
251
+ finished = true
252
+ case COMPRESSION_STATUS_OK:
253
+ // All input consumed and no output produced: the deflate stream is truncated.
254
+ if remaining == 0 && stream.pointee.src_size == 0 && produced == 0 {
255
+ throw ZipError.truncatedEntryData
256
+ }
257
+ default:
258
+ throw ZipError.corruptedEntryData
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,283 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+
9
+ /// Parses the ZIP end of central directory (including ZIP64) and the central directory entries.
10
+ /// Every offset and length is bounds-checked; malformed input throws `ZipError`.
11
+ enum ZipCentralDirectory {
12
+ private static let eocdSignature: UInt32 = 0x0605_4b50
13
+ private static let zip64EocdSignature: UInt32 = 0x0606_4b50
14
+ private static let zip64LocatorSignature: UInt32 = 0x0706_4b50
15
+ private static let entrySignature: UInt32 = 0x0201_4b50
16
+ private static let eocdSize = 22
17
+ private static let zip64LocatorSize = 20
18
+ private static let zip64EocdMinimumSize = 56
19
+ private static let entryMinimumSize = 46
20
+ private static let maxCommentLength = 0xFFFF
21
+ private static let zip64ExtraFieldId: UInt16 = 0x0001
22
+
23
+ private struct EntrySizes {
24
+ var uncompressed: UInt64
25
+ var compressed: UInt64
26
+ var localHeaderOffset: UInt64
27
+ }
28
+
29
+ private struct Location {
30
+ var totalEntries: UInt64
31
+ var size: UInt64
32
+ var offset: UInt64
33
+ /// The central directory must end before this offset (the EOCD or ZIP64 EOCD record).
34
+ var end: UInt64
35
+ }
36
+
37
+ static func readEntries(handle: FileHandle, fileSize: UInt64) throws -> (entries: [ZipEntry], centralDirectoryOffset: UInt64) {
38
+ let location = try locateCentralDirectory(handle: handle, fileSize: fileSize)
39
+ guard location.offset <= location.end, location.size <= location.end - location.offset else {
40
+ throw ZipError.invalidCentralDirectory
41
+ }
42
+ // Every central directory entry takes at least 46 bytes, which bounds the entry count.
43
+ guard location.totalEntries <= location.size / UInt64(entryMinimumSize), location.size <= UInt64(Int.max) else {
44
+ throw ZipError.invalidCentralDirectory
45
+ }
46
+
47
+ let data = try ZipBytes.readChunk(handle: handle, fileSize: fileSize, at: location.offset, count: Int(location.size))
48
+ var entries: [ZipEntry] = []
49
+ entries.reserveCapacity(Int(location.totalEntries))
50
+ var cursor = 0
51
+ for _ in 0..<location.totalEntries {
52
+ let (entry, next) = try parseEntry(data, at: cursor)
53
+ entries.append(entry)
54
+ cursor = next
55
+ }
56
+ guard cursor == data.count else {
57
+ throw ZipError.invalidCentralDirectory
58
+ }
59
+ return (entries, location.offset)
60
+ }
61
+
62
+ private static func locateCentralDirectory(handle: FileHandle, fileSize: UInt64) throws -> Location {
63
+ guard fileSize >= UInt64(eocdSize) else {
64
+ throw ZipError.missingEndOfCentralDirectory
65
+ }
66
+
67
+ // The EOCD record is at the end of the file, followed by an optional comment of up to 65535 bytes.
68
+ let tailLength = min(fileSize, UInt64(eocdSize + maxCommentLength))
69
+ let tailOffset = fileSize - tailLength
70
+ let tail = try ZipBytes.readChunk(handle: handle, fileSize: fileSize, at: tailOffset, count: Int(tailLength))
71
+
72
+ var eocdIndex: Int?
73
+ var index = tail.count - eocdSize
74
+ while index >= 0 {
75
+ if ZipBytes.uint32(tail, index) == eocdSignature,
76
+ index + eocdSize + Int(ZipBytes.uint16(tail, index + 20)) <= tail.count {
77
+ eocdIndex = index
78
+ break
79
+ }
80
+ index -= 1
81
+ }
82
+ guard let eocdIndex else {
83
+ throw ZipError.missingEndOfCentralDirectory
84
+ }
85
+
86
+ let eocdOffset = tailOffset + UInt64(eocdIndex)
87
+ let location = Location(
88
+ totalEntries: UInt64(ZipBytes.uint16(tail, eocdIndex + 10)),
89
+ size: UInt64(ZipBytes.uint32(tail, eocdIndex + 12)),
90
+ offset: UInt64(ZipBytes.uint32(tail, eocdIndex + 16)),
91
+ end: eocdOffset
92
+ )
93
+ let needsZip64 = location.totalEntries == 0xFFFF || location.size == 0xFFFF_FFFF || location.offset == 0xFFFF_FFFF
94
+ if let zip64Location = try readZip64Location(handle: handle, fileSize: fileSize, eocdOffset: eocdOffset) {
95
+ return zip64Location
96
+ }
97
+ if needsZip64 {
98
+ throw ZipError.invalidCentralDirectory
99
+ }
100
+ return location
101
+ }
102
+
103
+ private static func readZip64Location(handle: FileHandle, fileSize: UInt64, eocdOffset: UInt64) throws -> Location? {
104
+ guard eocdOffset >= UInt64(zip64LocatorSize) else {
105
+ return nil
106
+ }
107
+ let locatorOffset = eocdOffset - UInt64(zip64LocatorSize)
108
+ let locator = try ZipBytes.readChunk(handle: handle, fileSize: fileSize, at: locatorOffset, count: zip64LocatorSize)
109
+ guard ZipBytes.uint32(locator, 0) == zip64LocatorSignature else {
110
+ return nil
111
+ }
112
+ let recordOffset = ZipBytes.uint64(locator, 8)
113
+ guard recordOffset <= locatorOffset, locatorOffset - recordOffset >= UInt64(zip64EocdMinimumSize) else {
114
+ throw ZipError.invalidCentralDirectory
115
+ }
116
+ let record = try ZipBytes.readChunk(handle: handle, fileSize: fileSize, at: recordOffset, count: zip64EocdMinimumSize)
117
+ guard ZipBytes.uint32(record, 0) == zip64EocdSignature else {
118
+ throw ZipError.invalidCentralDirectory
119
+ }
120
+ return Location(
121
+ totalEntries: ZipBytes.uint64(record, 32),
122
+ size: ZipBytes.uint64(record, 40),
123
+ offset: ZipBytes.uint64(record, 48),
124
+ end: recordOffset
125
+ )
126
+ }
127
+
128
+ private static func parseEntry(_ data: Data, at offset: Int) throws -> (ZipEntry, Int) {
129
+ guard offset <= data.count - entryMinimumSize, ZipBytes.uint32(data, offset) == entrySignature else {
130
+ throw ZipError.invalidCentralDirectory
131
+ }
132
+ let versionMadeBy = ZipBytes.uint16(data, offset + 4)
133
+ let flags = ZipBytes.uint16(data, offset + 8)
134
+ let nameLength = Int(ZipBytes.uint16(data, offset + 28))
135
+ let extraLength = Int(ZipBytes.uint16(data, offset + 30))
136
+ let commentLength = Int(ZipBytes.uint16(data, offset + 32))
137
+ let externalAttributes = ZipBytes.uint32(data, offset + 38)
138
+
139
+ let nameStart = offset + entryMinimumSize
140
+ let extraStart = nameStart + nameLength
141
+ let next = extraStart + extraLength + commentLength
142
+ guard next <= data.count else {
143
+ throw ZipError.invalidCentralDirectory
144
+ }
145
+
146
+ var sizes = EntrySizes(
147
+ uncompressed: UInt64(ZipBytes.uint32(data, offset + 24)),
148
+ compressed: UInt64(ZipBytes.uint32(data, offset + 20)),
149
+ localHeaderOffset: UInt64(ZipBytes.uint32(data, offset + 42))
150
+ )
151
+ if sizes.uncompressed == 0xFFFF_FFFF || sizes.compressed == 0xFFFF_FFFF || sizes.localHeaderOffset == 0xFFFF_FFFF {
152
+ sizes = try applyZip64Extra(data, extraRange: extraStart..<(extraStart + extraLength), sizes: sizes)
153
+ }
154
+
155
+ // Same path decoding as ZIPFoundation: UTF-8 when general purpose bit 11 is set, CP437 otherwise.
156
+ let nameData = data.subdata(in: (data.startIndex + nameStart)..<(data.startIndex + extraStart))
157
+ let encoding: String.Encoding = flags & (1 << 11) != 0 ? .utf8 : codePage437
158
+ let path = String(data: nameData, encoding: encoding) ?? ""
159
+
160
+ let entry = ZipEntry(
161
+ path: path,
162
+ type: entryType(path: path, versionMadeBy: versionMadeBy, externalAttributes: externalAttributes),
163
+ generalPurposeFlags: flags,
164
+ compressionMethod: ZipBytes.uint16(data, offset + 10),
165
+ compressedSize: sizes.compressed,
166
+ uncompressedSize: sizes.uncompressed,
167
+ localHeaderOffset: sizes.localHeaderOffset
168
+ )
169
+ return (entry, next)
170
+ }
171
+
172
+ /// ZIP64 extended information: values are present only for fields saturated in the fixed record, in this order.
173
+ private static func applyZip64Extra(
174
+ _ data: Data,
175
+ extraRange: Range<Int>,
176
+ sizes: EntrySizes
177
+ ) throws -> EntrySizes {
178
+ var cursor = extraRange.lowerBound
179
+ while cursor + 4 <= extraRange.upperBound {
180
+ let headerId = ZipBytes.uint16(data, cursor)
181
+ let fieldStart = cursor + 4
182
+ let fieldEnd = fieldStart + Int(ZipBytes.uint16(data, cursor + 2))
183
+ guard fieldEnd <= extraRange.upperBound else {
184
+ throw ZipError.invalidCentralDirectory
185
+ }
186
+ if headerId != zip64ExtraFieldId {
187
+ cursor = fieldEnd
188
+ continue
189
+ }
190
+
191
+ var result = sizes
192
+ var valueCursor = fieldStart
193
+ func nextValue() throws -> UInt64 {
194
+ guard valueCursor + 8 <= fieldEnd else {
195
+ throw ZipError.invalidCentralDirectory
196
+ }
197
+ defer { valueCursor += 8 }
198
+ return ZipBytes.uint64(data, valueCursor)
199
+ }
200
+ if result.uncompressed == 0xFFFF_FFFF {
201
+ result.uncompressed = try nextValue()
202
+ }
203
+ if result.compressed == 0xFFFF_FFFF {
204
+ result.compressed = try nextValue()
205
+ }
206
+ if result.localHeaderOffset == 0xFFFF_FFFF {
207
+ result.localHeaderOffset = try nextValue()
208
+ }
209
+ return result
210
+ }
211
+ throw ZipError.invalidCentralDirectory
212
+ }
213
+
214
+ /// Mirrors ZIPFoundation's `Entry.type` so the same entries are treated as files, directories or symlinks.
215
+ static func entryType(path: String, versionMadeBy: UInt16, externalAttributes: UInt32) -> ZipEntryType {
216
+ let hostSystem = versionMadeBy >> 8
217
+ let hasDirectorySuffix = path.hasSuffix("/")
218
+ switch hostSystem {
219
+ case 3, 19: // UNIX, macOS
220
+ let mode = mode_t(UInt16(truncatingIfNeeded: externalAttributes >> 16)) & S_IFMT
221
+ switch mode {
222
+ case S_IFREG:
223
+ return .file
224
+ case S_IFDIR:
225
+ return .directory
226
+ case S_IFLNK:
227
+ return .symlink
228
+ default:
229
+ return hasDirectorySuffix ? .directory : .file
230
+ }
231
+ case 0: // MS-DOS
232
+ return hasDirectorySuffix || (externalAttributes & 0x10) != 0 ? .directory : .file
233
+ default:
234
+ return hasDirectorySuffix ? .directory : .file
235
+ }
236
+ }
237
+
238
+ private static let codePage437: String.Encoding = {
239
+ let encoding = CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.dosLatinUS.rawValue))
240
+ return String.Encoding(rawValue: encoding)
241
+ }()
242
+ }
243
+
244
+ /// Little-endian readers and bounded file reads for ZIP structures.
245
+ enum ZipBytes {
246
+ static func readChunk(handle: FileHandle, fileSize: UInt64, at offset: UInt64, count: Int) throws -> Data {
247
+ guard count >= 0, UInt64(count) <= fileSize, offset <= fileSize - UInt64(count) else {
248
+ throw ZipError.invalidCentralDirectory
249
+ }
250
+ if count == 0 {
251
+ return Data()
252
+ }
253
+ try handle.seek(toOffset: offset)
254
+ guard let data = try handle.read(upToCount: count), data.count == count else {
255
+ throw ZipError.unreadableArchive
256
+ }
257
+ return data
258
+ }
259
+
260
+ // Callers bounds-check `offset` against `data.count` before reading.
261
+ static func uint16(_ data: Data, _ offset: Int) -> UInt16 {
262
+ let base = data.startIndex + offset
263
+ return UInt16(data[base]) | (UInt16(data[base + 1]) << 8)
264
+ }
265
+
266
+ static func uint32(_ data: Data, _ offset: Int) -> UInt32 {
267
+ let base = data.startIndex + offset
268
+ var value: UInt32 = 0
269
+ for byteIndex in 0..<4 {
270
+ value |= UInt32(data[base + byteIndex]) << (8 * UInt32(byteIndex))
271
+ }
272
+ return value
273
+ }
274
+
275
+ static func uint64(_ data: Data, _ offset: Int) -> UInt64 {
276
+ let base = data.startIndex + offset
277
+ var value: UInt64 = 0
278
+ for byteIndex in 0..<8 {
279
+ value |= UInt64(data[base + byteIndex]) << (8 * UInt64(byteIndex))
280
+ }
281
+ return value
282
+ }
283
+ }