@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.
Files changed (56) hide show
  1. package/HotUpdater.podspec +7 -4
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +78 -21
  4. package/android/src/main/java/com/hotupdater/DecompressService.kt +83 -0
  5. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +26 -0
  6. package/android/src/main/java/com/hotupdater/HashUtils.kt +47 -0
  7. package/android/src/main/java/com/hotupdater/HotUpdater.kt +3 -0
  8. package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +3 -3
  9. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +3 -1
  10. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +293 -0
  11. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +105 -0
  12. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +117 -0
  13. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +175 -0
  14. package/android/src/newarch/HotUpdaterModule.kt +2 -0
  15. package/android/src/oldarch/HotUpdaterModule.kt +2 -0
  16. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +133 -60
  17. package/ios/HotUpdater/Internal/DecompressService.swift +89 -0
  18. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +22 -0
  19. package/ios/HotUpdater/Internal/HashUtils.swift +63 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +5 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +4 -4
  22. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +23 -10
  23. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +229 -0
  24. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +177 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +73 -7
  26. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +165 -0
  27. package/lib/commonjs/checkForUpdate.js +1 -0
  28. package/lib/commonjs/checkForUpdate.js.map +1 -1
  29. package/lib/commonjs/fetchUpdateInfo.js +1 -1
  30. package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
  31. package/lib/commonjs/native.js +3 -1
  32. package/lib/commonjs/native.js.map +1 -1
  33. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  34. package/lib/module/checkForUpdate.js +1 -0
  35. package/lib/module/checkForUpdate.js.map +1 -1
  36. package/lib/module/fetchUpdateInfo.js +1 -1
  37. package/lib/module/fetchUpdateInfo.js.map +1 -1
  38. package/lib/module/native.js +3 -1
  39. package/lib/module/native.js.map +1 -1
  40. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  41. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +5 -0
  44. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  45. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  46. package/lib/typescript/module/native.d.ts.map +1 -1
  47. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +5 -0
  48. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/checkForUpdate.ts +1 -0
  51. package/src/fetchUpdateInfo.ts +1 -1
  52. package/src/native.ts +6 -0
  53. package/src/specs/NativeHotUpdater.ts +5 -0
  54. package/android/src/main/java/com/hotupdater/HttpDownloadService.kt +0 -98
  55. package/android/src/main/java/com/hotupdater/ZipFileUnzipService.kt +0 -74
  56. 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 result of the download
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
+ }