@hot-updater/react-native 0.20.14 → 0.21.0
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.
- package/HotUpdater.podspec +7 -4
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +78 -21
- package/android/src/main/java/com/hotupdater/DecompressService.kt +83 -0
- package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +26 -0
- package/android/src/main/java/com/hotupdater/HashUtils.kt +47 -0
- package/android/src/main/java/com/hotupdater/HotUpdater.kt +3 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +3 -3
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +3 -1
- package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +293 -0
- package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +105 -0
- package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +117 -0
- package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +175 -0
- package/android/src/newarch/HotUpdaterModule.kt +2 -0
- package/android/src/oldarch/HotUpdaterModule.kt +2 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +133 -60
- package/ios/HotUpdater/Internal/DecompressService.swift +89 -0
- package/ios/HotUpdater/Internal/DecompressionStrategy.swift +22 -0
- package/ios/HotUpdater/Internal/HashUtils.swift +63 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +5 -2
- package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +4 -4
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +23 -10
- package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +229 -0
- package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +177 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +73 -7
- package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +165 -0
- package/lib/commonjs/checkForUpdate.js +1 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/fetchUpdateInfo.js +1 -1
- package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
- package/lib/commonjs/native.js +3 -1
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +1 -0
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/fetchUpdateInfo.js +1 -1
- package/lib/module/fetchUpdateInfo.js.map +1 -1
- package/lib/module/native.js +3 -1
- package/lib/module/native.js.map +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +5 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +5 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/checkForUpdate.ts +1 -0
- package/src/fetchUpdateInfo.ts +1 -1
- package/src/native.ts +6 -0
- package/src/specs/NativeHotUpdater.ts +5 -0
- package/android/src/main/java/com/hotupdater/HttpDownloadService.kt +0 -98
- package/android/src/main/java/com/hotupdater/ZipFileUnzipService.kt +0 -74
- package/ios/HotUpdater/Internal/SSZipArchiveUnzipService.swift +0 -25
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SWCompression
|
|
3
|
+
import Compression
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strategy for handling TAR+Brotli compressed files
|
|
7
|
+
*/
|
|
8
|
+
class TarBrDecompressionStrategy: DecompressionStrategy {
|
|
9
|
+
private static let MIN_FILE_SIZE: UInt64 = 10
|
|
10
|
+
|
|
11
|
+
func isValid(file: String) -> Bool {
|
|
12
|
+
guard FileManager.default.fileExists(atPath: file) else {
|
|
13
|
+
NSLog("[TarBrStrategy] Invalid file: doesn't exist")
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
do {
|
|
18
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: file)
|
|
19
|
+
guard let fileSize = attributes[.size] as? UInt64, fileSize >= Self.MIN_FILE_SIZE else {
|
|
20
|
+
NSLog("[TarBrStrategy] Invalid file: too small")
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
NSLog("[TarBrStrategy] Invalid file: cannot read attributes - \(error.localizedDescription)")
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Brotli has no standard magic bytes, check file extension
|
|
29
|
+
let lowercasedPath = file.lowercased()
|
|
30
|
+
let isBrotli = lowercasedPath.hasSuffix(".tar.br") || lowercasedPath.hasSuffix(".br")
|
|
31
|
+
|
|
32
|
+
if !isBrotli {
|
|
33
|
+
NSLog("[TarBrStrategy] Invalid file: not a .tar.br or .br file")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return isBrotli
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func decompress(file: String, to destination: String, progressHandler: @escaping (Double) -> Void) throws {
|
|
40
|
+
NSLog("[TarBrStrategy] Starting extraction of \(file) to \(destination)")
|
|
41
|
+
|
|
42
|
+
guard let compressedData = try? Data(contentsOf: URL(fileURLWithPath: file)) else {
|
|
43
|
+
throw NSError(
|
|
44
|
+
domain: "TarBrDecompressionStrategy",
|
|
45
|
+
code: 1,
|
|
46
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to read tar.br file at: \(file)"]
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
progressHandler(0.3)
|
|
51
|
+
let decompressedData: Data
|
|
52
|
+
do {
|
|
53
|
+
decompressedData = try decompressBrotli(compressedData)
|
|
54
|
+
NSLog("[TarBrStrategy] Brotli decompression successful, size: \(decompressedData.count) bytes")
|
|
55
|
+
progressHandler(0.6)
|
|
56
|
+
} catch {
|
|
57
|
+
throw NSError(
|
|
58
|
+
domain: "TarBrDecompressionStrategy",
|
|
59
|
+
code: 2,
|
|
60
|
+
userInfo: [NSLocalizedDescriptionKey: "Brotli decompression failed: \(error.localizedDescription)"]
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let tarEntries: [TarEntry]
|
|
65
|
+
do {
|
|
66
|
+
tarEntries = try TarContainer.open(container: decompressedData)
|
|
67
|
+
NSLog("[TarBrStrategy] Tar extraction successful, found \(tarEntries.count) entries")
|
|
68
|
+
} catch {
|
|
69
|
+
throw NSError(
|
|
70
|
+
domain: "TarBrDecompressionStrategy",
|
|
71
|
+
code: 3,
|
|
72
|
+
userInfo: [NSLocalizedDescriptionKey: "Tar extraction failed: \(error.localizedDescription)"]
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let destinationURL = URL(fileURLWithPath: destination)
|
|
77
|
+
let canonicalDestination = destinationURL.standardized.path
|
|
78
|
+
|
|
79
|
+
let fileManager = FileManager.default
|
|
80
|
+
if !fileManager.fileExists(atPath: canonicalDestination) {
|
|
81
|
+
try fileManager.createDirectory(
|
|
82
|
+
atPath: canonicalDestination,
|
|
83
|
+
withIntermediateDirectories: true,
|
|
84
|
+
attributes: nil
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let totalEntries = Double(tarEntries.count)
|
|
89
|
+
for (index, entry) in tarEntries.enumerated() {
|
|
90
|
+
try extractTarEntry(entry, to: canonicalDestination)
|
|
91
|
+
progressHandler(0.6 + (Double(index + 1) / totalEntries * 0.4))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
NSLog("[TarBrStrategy] Successfully extracted all entries")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private func decompressBrotli(_ data: Data) throws -> Data {
|
|
98
|
+
let bufferSize = 64 * 1024
|
|
99
|
+
var decompressedData = Data()
|
|
100
|
+
let count = data.count
|
|
101
|
+
|
|
102
|
+
var stream = compression_stream(
|
|
103
|
+
dst_ptr: UnsafeMutablePointer<UInt8>(bitPattern: 1)!,
|
|
104
|
+
dst_size: 0,
|
|
105
|
+
src_ptr: UnsafePointer<UInt8>(bitPattern: 1)!,
|
|
106
|
+
src_size: 0,
|
|
107
|
+
state: nil
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
let status = compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
|
|
111
|
+
|
|
112
|
+
guard status == COMPRESSION_STATUS_OK else {
|
|
113
|
+
throw NSError(
|
|
114
|
+
domain: "TarBrDecompressionStrategy",
|
|
115
|
+
code: 5,
|
|
116
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to initialize brotli decompression stream"]
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
defer {
|
|
121
|
+
compression_stream_destroy(&stream)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let outputBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
|
125
|
+
defer {
|
|
126
|
+
outputBuffer.deallocate()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
|
|
130
|
+
guard let baseAddress = rawBufferPointer.baseAddress else {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
stream.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
|
|
135
|
+
stream.src_size = count
|
|
136
|
+
|
|
137
|
+
var processStatus: compression_status
|
|
138
|
+
repeat {
|
|
139
|
+
stream.dst_ptr = outputBuffer
|
|
140
|
+
stream.dst_size = bufferSize
|
|
141
|
+
|
|
142
|
+
let flags = (stream.src_size == 0) ? Int32(bitPattern: COMPRESSION_STREAM_FINALIZE.rawValue) : Int32(0)
|
|
143
|
+
processStatus = compression_stream_process(&stream, flags)
|
|
144
|
+
|
|
145
|
+
switch processStatus {
|
|
146
|
+
case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
|
|
147
|
+
let outputSize = bufferSize - stream.dst_size
|
|
148
|
+
decompressedData.append(outputBuffer, count: outputSize)
|
|
149
|
+
case COMPRESSION_STATUS_ERROR:
|
|
150
|
+
break
|
|
151
|
+
default:
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
} while processStatus == COMPRESSION_STATUS_OK
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if decompressedData.isEmpty && !data.isEmpty {
|
|
158
|
+
throw NSError(
|
|
159
|
+
domain: "TarBrDecompressionStrategy",
|
|
160
|
+
code: 6,
|
|
161
|
+
userInfo: [NSLocalizedDescriptionKey: "Brotli decompression produced no output"]
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return decompressedData
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func extractTarEntry(_ entry: TarEntry, to destination: String) throws {
|
|
169
|
+
let entryInfo = entry.info
|
|
170
|
+
let entryName = entryInfo.name
|
|
171
|
+
|
|
172
|
+
if entryName.isEmpty || entryName == "./" || entryName == "." {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let targetURL = URL(fileURLWithPath: destination).appendingPathComponent(entryName)
|
|
177
|
+
let targetPath = targetURL.standardized.path
|
|
178
|
+
|
|
179
|
+
if !targetPath.hasPrefix(destination) {
|
|
180
|
+
throw NSError(
|
|
181
|
+
domain: "TarBrDecompressionStrategy",
|
|
182
|
+
code: 4,
|
|
183
|
+
userInfo: [
|
|
184
|
+
NSLocalizedDescriptionKey: "Path traversal detected",
|
|
185
|
+
"entry": entryName,
|
|
186
|
+
"targetPath": targetPath,
|
|
187
|
+
"destination": destination
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let fileManager = FileManager.default
|
|
193
|
+
|
|
194
|
+
switch entryInfo.type {
|
|
195
|
+
case .directory:
|
|
196
|
+
if !fileManager.fileExists(atPath: targetPath) {
|
|
197
|
+
try fileManager.createDirectory(
|
|
198
|
+
atPath: targetPath,
|
|
199
|
+
withIntermediateDirectories: true,
|
|
200
|
+
attributes: nil
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
NSLog("[TarBrStrategy] Created directory: \(entryName)")
|
|
204
|
+
|
|
205
|
+
case .regular:
|
|
206
|
+
let parentPath = targetURL.deletingLastPathComponent().path
|
|
207
|
+
if !fileManager.fileExists(atPath: parentPath) {
|
|
208
|
+
try fileManager.createDirectory(
|
|
209
|
+
atPath: parentPath,
|
|
210
|
+
withIntermediateDirectories: true,
|
|
211
|
+
attributes: nil
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if let data = entry.data {
|
|
216
|
+
try data.write(to: targetURL, options: .atomic)
|
|
217
|
+
NSLog("[TarBrStrategy] Extracted file: \(entryName) (\(data.count) bytes)")
|
|
218
|
+
} else {
|
|
219
|
+
NSLog("[TarBrStrategy] Warning: No data for file entry: \(entryName)")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case .symbolicLink:
|
|
223
|
+
NSLog("[TarBrStrategy] Skipping symbolic link: \(entryName)")
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
NSLog("[TarBrStrategy] Skipping unsupported entry type: \(entryName)")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SWCompression
|
|
3
|
+
import Compression
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strategy for handling TAR+GZIP compressed files
|
|
7
|
+
*/
|
|
8
|
+
class TarGzDecompressionStrategy: DecompressionStrategy {
|
|
9
|
+
private static let MIN_FILE_SIZE: UInt64 = 10
|
|
10
|
+
|
|
11
|
+
func isValid(file: String) -> Bool {
|
|
12
|
+
guard FileManager.default.fileExists(atPath: file) else {
|
|
13
|
+
NSLog("[TarGzStrategy] Invalid file: doesn't exist")
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
do {
|
|
18
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: file)
|
|
19
|
+
guard let fileSize = attributes[.size] as? UInt64, fileSize >= Self.MIN_FILE_SIZE else {
|
|
20
|
+
NSLog("[TarGzStrategy] Invalid file: too small")
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
NSLog("[TarGzStrategy] Invalid file: cannot read attributes - \(error.localizedDescription)")
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check GZIP magic bytes (0x1F 0x8B)
|
|
29
|
+
guard let fileHandle = FileHandle(forReadingAtPath: file) else {
|
|
30
|
+
NSLog("[TarGzStrategy] Invalid file: cannot open file")
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
defer {
|
|
35
|
+
fileHandle.closeFile()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
guard let header = try? fileHandle.read(upToCount: 2), header.count == 2 else {
|
|
39
|
+
NSLog("[TarGzStrategy] Invalid file: cannot read header")
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let isGzip = header[0] == 0x1F && header[1] == 0x8B
|
|
44
|
+
if !isGzip {
|
|
45
|
+
NSLog("[TarGzStrategy] Invalid file: wrong magic bytes (expected 0x1F 0x8B, got 0x\(String(format: "%02X", header[0])) 0x\(String(format: "%02X", header[1])))")
|
|
46
|
+
}
|
|
47
|
+
return isGzip
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func decompress(file: String, to destination: String, progressHandler: @escaping (Double) -> Void) throws {
|
|
51
|
+
NSLog("[TarGzStrategy] Starting extraction of \(file) to \(destination)")
|
|
52
|
+
|
|
53
|
+
guard let compressedData = try? Data(contentsOf: URL(fileURLWithPath: file)) else {
|
|
54
|
+
throw NSError(
|
|
55
|
+
domain: "TarGzDecompressionStrategy",
|
|
56
|
+
code: 1,
|
|
57
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to read tar.gz file at: \(file)"]
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
progressHandler(0.3)
|
|
62
|
+
let decompressedData: Data
|
|
63
|
+
do {
|
|
64
|
+
decompressedData = try decompressGzip(compressedData)
|
|
65
|
+
NSLog("[TarGzStrategy] GZIP decompression successful, size: \(decompressedData.count) bytes")
|
|
66
|
+
progressHandler(0.6)
|
|
67
|
+
} catch {
|
|
68
|
+
throw NSError(
|
|
69
|
+
domain: "TarGzDecompressionStrategy",
|
|
70
|
+
code: 2,
|
|
71
|
+
userInfo: [NSLocalizedDescriptionKey: "GZIP decompression failed: \(error.localizedDescription)"]
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let tarEntries: [TarEntry]
|
|
76
|
+
do {
|
|
77
|
+
tarEntries = try TarContainer.open(container: decompressedData)
|
|
78
|
+
NSLog("[TarGzStrategy] Tar extraction successful, found \(tarEntries.count) entries")
|
|
79
|
+
} catch {
|
|
80
|
+
throw NSError(
|
|
81
|
+
domain: "TarGzDecompressionStrategy",
|
|
82
|
+
code: 3,
|
|
83
|
+
userInfo: [NSLocalizedDescriptionKey: "Tar extraction failed: \(error.localizedDescription)"]
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let destinationURL = URL(fileURLWithPath: destination)
|
|
88
|
+
let canonicalDestination = destinationURL.standardized.path
|
|
89
|
+
|
|
90
|
+
let fileManager = FileManager.default
|
|
91
|
+
if !fileManager.fileExists(atPath: canonicalDestination) {
|
|
92
|
+
try fileManager.createDirectory(
|
|
93
|
+
atPath: canonicalDestination,
|
|
94
|
+
withIntermediateDirectories: true,
|
|
95
|
+
attributes: nil
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let totalEntries = Double(tarEntries.count)
|
|
100
|
+
for (index, entry) in tarEntries.enumerated() {
|
|
101
|
+
try extractTarEntry(entry, to: canonicalDestination)
|
|
102
|
+
progressHandler(0.6 + (Double(index + 1) / totalEntries * 0.4))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
NSLog("[TarGzStrategy] Successfully extracted all entries")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private func decompressGzip(_ data: Data) throws -> Data {
|
|
109
|
+
do {
|
|
110
|
+
let decompressedData = try GzipArchive.unarchive(archive: data)
|
|
111
|
+
NSLog("[TarGzStrategy] GZIP decompression successful using SWCompression")
|
|
112
|
+
return decompressedData
|
|
113
|
+
} catch {
|
|
114
|
+
throw NSError(
|
|
115
|
+
domain: "TarGzDecompressionStrategy",
|
|
116
|
+
code: 5,
|
|
117
|
+
userInfo: [NSLocalizedDescriptionKey: "GZIP decompression failed: \(error.localizedDescription)"]
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func extractTarEntry(_ entry: TarEntry, to destination: String) throws {
|
|
123
|
+
let fileManager = FileManager.default
|
|
124
|
+
let entryPath = entry.info.name.trimmingCharacters(in: .init(charactersIn: "/"))
|
|
125
|
+
|
|
126
|
+
guard !entryPath.isEmpty,
|
|
127
|
+
!entryPath.contains(".."),
|
|
128
|
+
!entryPath.hasPrefix("/") else {
|
|
129
|
+
NSLog("[TarGzStrategy] Skipping suspicious path: \(entry.info.name)")
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let fullPath = (destination as NSString).appendingPathComponent(entryPath)
|
|
134
|
+
let fullURL = URL(fileURLWithPath: fullPath)
|
|
135
|
+
let canonicalFullPath = fullURL.standardized.path
|
|
136
|
+
let canonicalDestination = URL(fileURLWithPath: destination).standardized.path
|
|
137
|
+
|
|
138
|
+
guard canonicalFullPath.hasPrefix(canonicalDestination + "/") ||
|
|
139
|
+
canonicalFullPath == canonicalDestination else {
|
|
140
|
+
throw NSError(
|
|
141
|
+
domain: "TarGzDecompressionStrategy",
|
|
142
|
+
code: 4,
|
|
143
|
+
userInfo: [NSLocalizedDescriptionKey: "Path traversal attempt detected: \(entry.info.name)"]
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if entry.info.type == .directory {
|
|
148
|
+
if !fileManager.fileExists(atPath: canonicalFullPath) {
|
|
149
|
+
try fileManager.createDirectory(
|
|
150
|
+
atPath: canonicalFullPath,
|
|
151
|
+
withIntermediateDirectories: true,
|
|
152
|
+
attributes: nil
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if entry.info.type == .regular {
|
|
159
|
+
let parentPath = (canonicalFullPath as NSString).deletingLastPathComponent
|
|
160
|
+
if !fileManager.fileExists(atPath: parentPath) {
|
|
161
|
+
try fileManager.createDirectory(
|
|
162
|
+
atPath: parentPath,
|
|
163
|
+
withIntermediateDirectories: true,
|
|
164
|
+
attributes: nil
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
guard let data = entry.data else {
|
|
169
|
+
NSLog("[TarGzStrategy] Skipping file with no data: \(entry.info.name)")
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try data.write(to: URL(fileURLWithPath: canonicalFullPath))
|
|
174
|
+
NSLog("[TarGzStrategy] Extracted: \(entryPath)")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -1,30 +1,70 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
3
|
protocol DownloadService {
|
|
4
|
+
/**
|
|
5
|
+
* Gets the file size from the URL without downloading.
|
|
6
|
+
* @param url The URL to check
|
|
7
|
+
* @param completion Callback with file size or error
|
|
8
|
+
*/
|
|
9
|
+
func getFileSize(from url: URL, completion: @escaping (Result<Int64, Error>) -> Void)
|
|
10
|
+
|
|
4
11
|
/**
|
|
5
12
|
* Downloads a file from a URL.
|
|
6
13
|
* @param url The URL to download from
|
|
7
14
|
* @param destination The local path to save to
|
|
8
15
|
* @param progressHandler Callback for download progress updates
|
|
9
|
-
* @param completion Callback with
|
|
16
|
+
* @param completion Callback with downloaded file URL or error
|
|
10
17
|
* @return The download task (optional)
|
|
11
18
|
*/
|
|
12
19
|
func downloadFile(from url: URL, to destination: String, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask?
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
|
|
23
|
+
enum DownloadError: Error {
|
|
24
|
+
case incompleteDownload
|
|
25
|
+
case invalidContentLength
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
class URLSessionDownloadService: NSObject, DownloadService {
|
|
17
29
|
private var session: URLSession!
|
|
18
30
|
private var progressHandlers: [URLSessionTask: (Double) -> Void] = [:]
|
|
19
31
|
private var completionHandlers: [URLSessionTask: (Result<URL, Error>) -> Void] = [:]
|
|
20
32
|
private var destinations: [URLSessionTask: String] = [:]
|
|
21
|
-
|
|
33
|
+
|
|
22
34
|
override init() {
|
|
23
35
|
super.init()
|
|
24
36
|
let configuration = URLSessionConfiguration.default
|
|
25
37
|
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
26
38
|
}
|
|
27
|
-
|
|
39
|
+
|
|
40
|
+
func getFileSize(from url: URL, completion: @escaping (Result<Int64, Error>) -> Void) {
|
|
41
|
+
var request = URLRequest(url: url)
|
|
42
|
+
request.httpMethod = "HEAD"
|
|
43
|
+
|
|
44
|
+
let task = session.dataTask(with: request) { _, response, error in
|
|
45
|
+
if let error = error {
|
|
46
|
+
NSLog("[DownloadService] HEAD request failed: \(error.localizedDescription)")
|
|
47
|
+
completion(.failure(error))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
52
|
+
completion(.failure(DownloadError.invalidContentLength))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let contentLength = httpResponse.expectedContentLength
|
|
57
|
+
if contentLength > 0 {
|
|
58
|
+
NSLog("[DownloadService] File size from HEAD request: \(contentLength) bytes")
|
|
59
|
+
completion(.success(contentLength))
|
|
60
|
+
} else {
|
|
61
|
+
NSLog("[DownloadService] Invalid content length: \(contentLength)")
|
|
62
|
+
completion(.failure(DownloadError.invalidContentLength))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
task.resume()
|
|
66
|
+
}
|
|
67
|
+
|
|
28
68
|
func downloadFile(from url: URL, to destination: String, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask? {
|
|
29
69
|
let task = session.downloadTask(with: url)
|
|
30
70
|
progressHandlers[task] = progressHandler
|
|
@@ -39,24 +79,50 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
39
79
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
|
40
80
|
let completion = completionHandlers[downloadTask]
|
|
41
81
|
let destination = destinations[downloadTask]
|
|
42
|
-
|
|
82
|
+
|
|
43
83
|
defer {
|
|
44
84
|
progressHandlers.removeValue(forKey: downloadTask)
|
|
45
85
|
completionHandlers.removeValue(forKey: downloadTask)
|
|
46
86
|
destinations.removeValue(forKey: downloadTask)
|
|
47
|
-
|
|
87
|
+
|
|
48
88
|
// 다운로드 완료 알림
|
|
49
89
|
NotificationCenter.default.post(name: .downloadDidFinish, object: downloadTask)
|
|
50
90
|
}
|
|
51
|
-
|
|
91
|
+
|
|
52
92
|
guard let destination = destination else {
|
|
53
93
|
completion?(.failure(NSError(domain: "HotUpdaterError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Destination path not found"])))
|
|
54
94
|
return
|
|
55
95
|
}
|
|
56
|
-
|
|
96
|
+
|
|
97
|
+
// Verify file size
|
|
98
|
+
let expectedSize = downloadTask.response?.expectedContentLength ?? -1
|
|
99
|
+
let actualSize: Int64?
|
|
100
|
+
do {
|
|
101
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
|
|
102
|
+
actualSize = attributes[.size] as? Int64
|
|
103
|
+
} catch {
|
|
104
|
+
NSLog("[DownloadService] Failed to get file attributes: \(error.localizedDescription)")
|
|
105
|
+
actualSize = nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if expectedSize > 0, let actualSize = actualSize, actualSize != expectedSize {
|
|
109
|
+
NSLog("[DownloadService] Download incomplete: \(actualSize) / \(expectedSize) bytes")
|
|
110
|
+
// Delete incomplete file
|
|
111
|
+
try? FileManager.default.removeItem(at: location)
|
|
112
|
+
completion?(.failure(DownloadError.incompleteDownload))
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
57
116
|
do {
|
|
58
117
|
let destinationURL = URL(fileURLWithPath: destination)
|
|
118
|
+
|
|
119
|
+
// Delete existing file if needed
|
|
120
|
+
if FileManager.default.fileExists(atPath: destination) {
|
|
121
|
+
try FileManager.default.removeItem(at: destinationURL)
|
|
122
|
+
}
|
|
123
|
+
|
|
59
124
|
try FileManager.default.copyItem(at: location, to: destinationURL)
|
|
125
|
+
NSLog("[DownloadService] Download completed successfully: \(actualSize ?? 0) bytes")
|
|
60
126
|
completion?(.success(destinationURL))
|
|
61
127
|
} catch {
|
|
62
128
|
NSLog("[DownloadService] Failed to copy downloaded file: \(error.localizedDescription)")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SWCompression
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Strategy for handling ZIP compressed files
|
|
6
|
+
*/
|
|
7
|
+
class ZipDecompressionStrategy: DecompressionStrategy {
|
|
8
|
+
private static let ZIP_MAGIC_NUMBER: [UInt8] = [0x50, 0x4B, 0x03, 0x04]
|
|
9
|
+
private static let MIN_ZIP_SIZE: UInt64 = 22
|
|
10
|
+
|
|
11
|
+
func isValid(file: String) -> Bool {
|
|
12
|
+
guard FileManager.default.fileExists(atPath: file) else {
|
|
13
|
+
NSLog("[ZipStrategy] Invalid ZIP: file doesn't exist")
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
do {
|
|
18
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: file)
|
|
19
|
+
guard let fileSize = attributes[.size] as? UInt64, fileSize >= Self.MIN_ZIP_SIZE else {
|
|
20
|
+
NSLog("[ZipStrategy] Invalid ZIP: file too small")
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
NSLog("[ZipStrategy] Invalid ZIP: cannot read attributes - \(error.localizedDescription)")
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
guard let fileHandle = FileHandle(forReadingAtPath: file) else {
|
|
29
|
+
NSLog("[ZipStrategy] Invalid ZIP: cannot open file")
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
defer {
|
|
34
|
+
fileHandle.closeFile()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
guard let header = try? fileHandle.read(upToCount: 4), header.count == 4 else {
|
|
38
|
+
NSLog("[ZipStrategy] Invalid ZIP: cannot read header")
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let magicBytes = [UInt8](header)
|
|
43
|
+
guard magicBytes == Self.ZIP_MAGIC_NUMBER else {
|
|
44
|
+
NSLog("[ZipStrategy] Invalid ZIP: wrong magic number")
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
guard let zipData = try? Data(contentsOf: URL(fileURLWithPath: file)) else {
|
|
49
|
+
NSLog("[ZipStrategy] Invalid ZIP: cannot read file data")
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
do {
|
|
54
|
+
_ = try ZipContainer.open(container: zipData)
|
|
55
|
+
return true
|
|
56
|
+
} catch {
|
|
57
|
+
NSLog("[ZipStrategy] Invalid ZIP: structure validation failed - \(error.localizedDescription)")
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func decompress(file: String, to destination: String, progressHandler: @escaping (Double) -> Void) throws {
|
|
63
|
+
NSLog("[ZipStrategy] Starting extraction of \(file) to \(destination)")
|
|
64
|
+
|
|
65
|
+
guard let zipData = try? Data(contentsOf: URL(fileURLWithPath: file)) else {
|
|
66
|
+
throw NSError(
|
|
67
|
+
domain: "ZipDecompressionStrategy",
|
|
68
|
+
code: 1,
|
|
69
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to read ZIP file at: \(file)"]
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
progressHandler(0.1)
|
|
74
|
+
|
|
75
|
+
let zipEntries: [ZipEntry]
|
|
76
|
+
do {
|
|
77
|
+
zipEntries = try ZipContainer.open(container: zipData)
|
|
78
|
+
NSLog("[ZipStrategy] ZIP extraction successful, found \(zipEntries.count) entries")
|
|
79
|
+
} catch {
|
|
80
|
+
throw NSError(
|
|
81
|
+
domain: "ZipDecompressionStrategy",
|
|
82
|
+
code: 2,
|
|
83
|
+
userInfo: [NSLocalizedDescriptionKey: "ZIP extraction failed: \(error.localizedDescription)"]
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
progressHandler(0.2)
|
|
88
|
+
|
|
89
|
+
let destinationURL = URL(fileURLWithPath: destination)
|
|
90
|
+
let canonicalDestination = destinationURL.standardized.path
|
|
91
|
+
|
|
92
|
+
let fileManager = FileManager.default
|
|
93
|
+
if !fileManager.fileExists(atPath: canonicalDestination) {
|
|
94
|
+
try fileManager.createDirectory(
|
|
95
|
+
atPath: canonicalDestination,
|
|
96
|
+
withIntermediateDirectories: true,
|
|
97
|
+
attributes: nil
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let totalEntries = Double(zipEntries.count)
|
|
102
|
+
for (index, entry) in zipEntries.enumerated() {
|
|
103
|
+
try extractZipEntry(entry, to: canonicalDestination)
|
|
104
|
+
progressHandler(0.2 + (Double(index + 1) / totalEntries * 0.8))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
NSLog("[ZipStrategy] Successfully extracted all entries")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func extractZipEntry(_ entry: ZipEntry, to destination: String) throws {
|
|
111
|
+
let fileManager = FileManager.default
|
|
112
|
+
let entryPath = entry.info.name.trimmingCharacters(in: .init(charactersIn: "/"))
|
|
113
|
+
|
|
114
|
+
guard !entryPath.isEmpty,
|
|
115
|
+
!entryPath.contains(".."),
|
|
116
|
+
!entryPath.hasPrefix("/") else {
|
|
117
|
+
NSLog("[ZipStrategy] Skipping suspicious path: \(entry.info.name)")
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let fullPath = (destination as NSString).appendingPathComponent(entryPath)
|
|
122
|
+
let fullURL = URL(fileURLWithPath: fullPath)
|
|
123
|
+
let canonicalFullPath = fullURL.standardized.path
|
|
124
|
+
let canonicalDestination = URL(fileURLWithPath: destination).standardized.path
|
|
125
|
+
|
|
126
|
+
guard canonicalFullPath.hasPrefix(canonicalDestination + "/") ||
|
|
127
|
+
canonicalFullPath == canonicalDestination else {
|
|
128
|
+
throw NSError(
|
|
129
|
+
domain: "ZipDecompressionStrategy",
|
|
130
|
+
code: 3,
|
|
131
|
+
userInfo: [NSLocalizedDescriptionKey: "Path traversal attempt detected: \(entry.info.name)"]
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if entry.info.type == .directory {
|
|
136
|
+
if !fileManager.fileExists(atPath: canonicalFullPath) {
|
|
137
|
+
try fileManager.createDirectory(
|
|
138
|
+
atPath: canonicalFullPath,
|
|
139
|
+
withIntermediateDirectories: true,
|
|
140
|
+
attributes: nil
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if entry.info.type == .regular {
|
|
147
|
+
let parentPath = (canonicalFullPath as NSString).deletingLastPathComponent
|
|
148
|
+
if !fileManager.fileExists(atPath: parentPath) {
|
|
149
|
+
try fileManager.createDirectory(
|
|
150
|
+
atPath: parentPath,
|
|
151
|
+
withIntermediateDirectories: true,
|
|
152
|
+
attributes: nil
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
guard let data = entry.data else {
|
|
157
|
+
NSLog("[ZipStrategy] Skipping file with no data: \(entry.info.name)")
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try data.write(to: URL(fileURLWithPath: canonicalFullPath))
|
|
162
|
+
NSLog("[ZipStrategy] Extracted: \(entryPath)")
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|