@otakit/capacitor-updater 2.3.1 → 2.3.3

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/Package.swift CHANGED
@@ -22,6 +22,13 @@ let package = Package(
22
22
  .product(name: "ZIPFoundation", package: "ZIPFoundation")
23
23
  ],
24
24
  path: "ios/Sources/UpdaterPlugin",
25
- exclude: ["UpdaterPlugin.m"])
25
+ exclude: ["UpdaterPlugin.m"]),
26
+ .testTarget(
27
+ name: "UpdaterPluginTests",
28
+ dependencies: [
29
+ "UpdaterPlugin",
30
+ .product(name: "ZIPFoundation", package: "ZIPFoundation")
31
+ ],
32
+ path: "ios/Tests/UpdaterPluginTests")
26
33
  ]
27
34
  )
@@ -319,7 +319,7 @@ public class UpdaterPlugin extends Plugin {
319
319
  if (hasStagedBundle) {
320
320
  resolveCurrentRuntimeKey();
321
321
  try {
322
- if (!applyStaged(false)) {
322
+ if (!applyStaged()) {
323
323
  android.util.Log.w(
324
324
  "OtaKit",
325
325
  "Failed to apply a valid staged bundle during runtime handling"
@@ -334,7 +334,7 @@ public class UpdaterPlugin extends Plugin {
334
334
  DownloadResolution result = downloadLatest(false, null);
335
335
  resolveCurrentRuntimeKey();
336
336
  if (result.isForcedStaged()) {
337
- requireApplyStaged(true);
337
+ requireApplyStaged();
338
338
  }
339
339
  });
340
340
  return;
@@ -343,7 +343,7 @@ public class UpdaterPlugin extends Plugin {
343
343
  DownloadResolution result = downloadLatest(false, null);
344
344
  resolveCurrentRuntimeKey();
345
345
  if (result.isForcedStaged()) {
346
- requireApplyStaged(true);
346
+ requireApplyStaged();
347
347
  }
348
348
  });
349
349
  return;
@@ -355,7 +355,7 @@ public class UpdaterPlugin extends Plugin {
355
355
  return;
356
356
  }
357
357
  resolveCurrentRuntimeKey();
358
- requireApplyStaged(true);
358
+ requireApplyStaged();
359
359
  });
360
360
  return;
361
361
  }
@@ -367,7 +367,7 @@ public class UpdaterPlugin extends Plugin {
367
367
  return;
368
368
  case APPLY_STAGED:
369
369
  try {
370
- if (applyStaged(false)) {
370
+ if (applyStaged()) {
371
371
  return;
372
372
  }
373
373
  } catch (Exception e) {
@@ -377,7 +377,7 @@ public class UpdaterPlugin extends Plugin {
377
377
  executeAutomaticUpdate("launch apply-staged fallback", () -> {
378
378
  DownloadResolution result = downloadLatest(false, null);
379
379
  if (result.isForcedStaged()) {
380
- requireApplyStaged(true);
380
+ requireApplyStaged();
381
381
  }
382
382
  });
383
383
  return;
@@ -385,7 +385,7 @@ public class UpdaterPlugin extends Plugin {
385
385
  executeAutomaticUpdate("launch shadow", () -> {
386
386
  DownloadResolution result = downloadLatest(false, null);
387
387
  if (result.isForcedStaged()) {
388
- requireApplyStaged(true);
388
+ requireApplyStaged();
389
389
  }
390
390
  });
391
391
  return;
@@ -393,7 +393,7 @@ public class UpdaterPlugin extends Plugin {
393
393
  executeAutomaticUpdate("launch immediate", () -> {
394
394
  DownloadResolution result = downloadLatest(false, null);
395
395
  if ("staged".equals(result.kind)) {
396
- requireApplyStaged(true);
396
+ requireApplyStaged();
397
397
  }
398
398
  });
399
399
  return;
@@ -406,12 +406,12 @@ public class UpdaterPlugin extends Plugin {
406
406
  return;
407
407
  case APPLY_STAGED:
408
408
  executeAutomaticUpdate("resume apply-staged", () -> {
409
- if (applyStaged(true)) {
409
+ if (applyStaged()) {
410
410
  return;
411
411
  }
412
412
  DownloadResolution result = downloadLatest(true, null);
413
413
  if (result.isForcedStaged()) {
414
- requireApplyStaged(true);
414
+ requireApplyStaged();
415
415
  }
416
416
  });
417
417
  return;
@@ -419,7 +419,7 @@ public class UpdaterPlugin extends Plugin {
419
419
  executeAutomaticUpdate("resume shadow", () -> {
420
420
  DownloadResolution result = downloadLatest(true, null);
421
421
  if (result.isForcedStaged()) {
422
- requireApplyStaged(true);
422
+ requireApplyStaged();
423
423
  }
424
424
  });
425
425
  return;
@@ -427,7 +427,7 @@ public class UpdaterPlugin extends Plugin {
427
427
  executeAutomaticUpdate("resume immediate", () -> {
428
428
  DownloadResolution result = downloadLatest(false, null);
429
429
  if ("staged".equals(result.kind)) {
430
- requireApplyStaged(true);
430
+ requireApplyStaged();
431
431
  }
432
432
  });
433
433
  return;
@@ -539,7 +539,7 @@ public class UpdaterPlugin extends Plugin {
539
539
  }
540
540
  executor.execute(() -> {
541
541
  try {
542
- requireApplyStaged(true);
542
+ requireApplyStaged();
543
543
  } catch (Exception e) {
544
544
  call.reject("apply failed: " + e.getMessage());
545
545
  } finally {
@@ -558,7 +558,7 @@ public class UpdaterPlugin extends Plugin {
558
558
  try {
559
559
  DownloadResolution result = downloadLatest(false, null);
560
560
  if ("staged".equals(result.kind)) {
561
- requireApplyStaged(true);
561
+ requireApplyStaged();
562
562
  return;
563
563
  }
564
564
  call.resolve();
@@ -983,7 +983,7 @@ public class UpdaterPlugin extends Plugin {
983
983
  throw new IllegalStateException("Bundle archive does not contain index.html");
984
984
  }
985
985
 
986
- private boolean applyStaged(boolean reloadAfterApply) throws Exception {
986
+ private boolean applyStaged() throws Exception {
987
987
  UpdaterCoordinator.ApplyPreparation preparation = coordinator.prepareApplyStaged(
988
988
  this::isCompatibleRuntime,
989
989
  this::isBundleUsable
@@ -994,9 +994,6 @@ public class UpdaterPlugin extends Plugin {
994
994
  }
995
995
 
996
996
  applyServerBasePathSynchronously(preparation.activationPath);
997
- if (reloadAfterApply) {
998
- reloadWebViewSynchronously();
999
- }
1000
997
 
1001
998
  cancelTrialTimeout();
1002
999
  if (preparation.trialBundleId != null) {
@@ -1005,8 +1002,8 @@ public class UpdaterPlugin extends Plugin {
1005
1002
  return true;
1006
1003
  }
1007
1004
 
1008
- private void requireApplyStaged(boolean reloadAfterApply) throws Exception {
1009
- if (!applyStaged(reloadAfterApply)) {
1005
+ private void requireApplyStaged() throws Exception {
1006
+ if (!applyStaged()) {
1010
1007
  throw new IllegalStateException("Expected a staged bundle to be ready for apply");
1011
1008
  }
1012
1009
  }
@@ -1015,7 +1012,7 @@ public class UpdaterPlugin extends Plugin {
1015
1012
  cancelTrialTimeout();
1016
1013
  trialTimeoutRunnable = () -> {
1017
1014
  if (coordinator.isCurrentTrialBundle(bundleId)) {
1018
- rollbackCurrentBundle("notify_timeout", true);
1015
+ rollbackCurrentBundle("notify_timeout");
1019
1016
  }
1020
1017
  };
1021
1018
  mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
@@ -1028,7 +1025,7 @@ public class UpdaterPlugin extends Plugin {
1028
1025
  }
1029
1026
  }
1030
1027
 
1031
- private void rollbackCurrentBundle(String reason, boolean shouldReload) {
1028
+ private void rollbackCurrentBundle(String reason) {
1032
1029
  cancelTrialTimeout();
1033
1030
  UpdaterCoordinator.RollbackPreparation preparation = coordinator.prepareRollback(
1034
1031
  reason,
@@ -1056,9 +1053,6 @@ public class UpdaterPlugin extends Plugin {
1056
1053
 
1057
1054
  try {
1058
1055
  applyServerBasePathSynchronously(preparation.activationPath);
1059
- if (shouldReload) {
1060
- reloadWebViewSynchronously();
1061
- }
1062
1056
  } catch (Exception e) {
1063
1057
  android.util.Log.w("OtaKit", "rollback activation failed", e);
1064
1058
  }
@@ -1069,20 +1063,12 @@ public class UpdaterPlugin extends Plugin {
1069
1063
  if (bridge == null) {
1070
1064
  throw new IllegalStateException("Bridge not available for activation");
1071
1065
  }
1072
- if (path == null || path.isEmpty()) {
1073
- bridge.setServerAssetPath(BUILTIN_ASSET_PATH);
1074
- } else {
1075
- bridge.setServerBasePath(path);
1076
- }
1077
- });
1078
- }
1079
-
1080
- private void reloadWebViewSynchronously() throws Exception {
1081
- runOnMainSynchronously(() -> {
1082
- if (bridge == null || bridge.getWebView() == null) {
1083
- throw new IllegalStateException("WebView not available for reload");
1084
- }
1085
- bridge.getWebView().reload();
1066
+ WebViewActivation.activate(
1067
+ path,
1068
+ BUILTIN_ASSET_PATH,
1069
+ bridge::setServerBasePath,
1070
+ bridge::setServerAssetPath
1071
+ );
1086
1072
  });
1087
1073
  }
1088
1074
 
@@ -0,0 +1,26 @@
1
+ package com.otakit.updater;
2
+
3
+ final class WebViewActivation {
4
+
5
+ @FunctionalInterface
6
+ interface PathActivator {
7
+ void activate(String path);
8
+ }
9
+
10
+ private WebViewActivation() {}
11
+
12
+ static void activate(
13
+ String path,
14
+ String builtinAssetPath,
15
+ PathActivator setServerBasePath,
16
+ PathActivator setServerAssetPath
17
+ ) {
18
+ // Capacitor's Android setters both switch the hosted files and enqueue the
19
+ // WebView navigation. Calling WebView.reload() as well causes two navigations.
20
+ if (path == null || path.isEmpty()) {
21
+ setServerAssetPath.activate(builtinAssetPath);
22
+ } else {
23
+ setServerBasePath.activate(path);
24
+ }
25
+ }
26
+ }
@@ -24,7 +24,9 @@ final class ZipUtils {
24
24
  ZipEntry entry;
25
25
  while ((entry = zis.getNextEntry()) != null) {
26
26
  String name = entry.getName();
27
- if (name.contains("..") || name.startsWith("/") || name.startsWith("\\")) {
27
+ if (
28
+ containsParentDirectoryComponent(name) || name.startsWith("/") || name.startsWith("\\")
29
+ ) {
28
30
  throw new SecurityException("Zip path traversal attempt: " + name);
29
31
  }
30
32
 
@@ -69,4 +71,13 @@ final class ZipUtils {
69
71
  }
70
72
  }
71
73
  }
74
+
75
+ static boolean containsParentDirectoryComponent(String path) {
76
+ for (String component : path.split("/", -1)) {
77
+ if (component.equals("..")) {
78
+ return true;
79
+ }
80
+ }
81
+ return false;
82
+ }
72
83
  }
@@ -1,7 +1,7 @@
1
1
  import Foundation
2
2
  import ZIPFoundation
3
3
 
4
- enum ZipUtilsError: Error {
4
+ enum ZipUtilsError: LocalizedError {
5
5
  case invalidZip
6
6
  case pathTraversal(String)
7
7
  case absolutePath(String)
@@ -9,6 +9,25 @@ enum ZipUtilsError: Error {
9
9
  case unsupportedEntry(String)
10
10
  case fileCountExceeded(Int)
11
11
  case totalSizeExceeded(UInt64)
12
+
13
+ var errorDescription: String? {
14
+ switch self {
15
+ case .invalidZip:
16
+ return "Downloaded bundle is not a readable ZIP archive"
17
+ case let .pathTraversal(path):
18
+ return "ZIP entry contains a parent-directory path component: \(path)"
19
+ case let .absolutePath(path):
20
+ return "ZIP entry uses an absolute path: \(path)"
21
+ case let .symlinkNotAllowed(path):
22
+ return "ZIP entry is a symbolic link: \(path)"
23
+ case let .unsupportedEntry(path):
24
+ return "ZIP entry has an unsupported type: \(path)"
25
+ case let .fileCountExceeded(count):
26
+ return "ZIP archive contains too many files: \(count)"
27
+ case let .totalSizeExceeded(size):
28
+ return "ZIP archive expands beyond the size limit: \(size) bytes"
29
+ }
30
+ }
12
31
  }
13
32
 
14
33
  final class ZipUtils {
@@ -16,51 +35,14 @@ final class ZipUtils {
16
35
  private let maxTotalSize: UInt64 = 500_000_000 // 500 MB
17
36
 
18
37
  func extractSecurely(zipURL: URL, to destination: URL) throws {
19
- guard let archive = Archive(url: zipURL, accessMode: .read) else {
38
+ let archive: Archive
39
+ do {
40
+ archive = try Archive(url: zipURL, accessMode: .read, pathEncoding: nil)
41
+ } catch {
20
42
  throw ZipUtilsError.invalidZip
21
43
  }
22
44
 
23
- let destinationPath = destination.standardizedFileURL.path
24
- let destinationPrefix =
25
- destinationPath.hasSuffix("/") ? destinationPath : "\(destinationPath)/"
26
-
27
- var fileCount = 0
28
- var totalSize: UInt64 = 0
29
-
30
- for entry in archive {
31
- if entry.path.hasPrefix("/") {
32
- throw ZipUtilsError.absolutePath(entry.path)
33
- }
34
- if entry.path.contains("..") {
35
- throw ZipUtilsError.pathTraversal(entry.path)
36
- }
37
- if entry.type == .symlink {
38
- throw ZipUtilsError.symlinkNotAllowed(entry.path)
39
- }
40
- if entry.type != .directory && entry.type != .file {
41
- throw ZipUtilsError.unsupportedEntry(entry.path)
42
- }
43
-
44
- let outputURL = destination
45
- .appendingPathComponent(entry.path)
46
- .standardizedFileURL
47
- let outputPath = outputURL.path
48
- if !(outputPath == destinationPath || outputPath.hasPrefix(destinationPrefix))
49
- {
50
- throw ZipUtilsError.pathTraversal(entry.path)
51
- }
52
-
53
- if entry.type == .file {
54
- fileCount += 1
55
- if fileCount > maxFiles {
56
- throw ZipUtilsError.fileCountExceeded(fileCount)
57
- }
58
- totalSize += entry.uncompressedSize
59
- if totalSize > maxTotalSize {
60
- throw ZipUtilsError.totalSizeExceeded(totalSize)
61
- }
62
- }
63
- }
45
+ try validateEntries(in: archive, destination: destination)
64
46
 
65
47
  try FileManager.default.createDirectory(
66
48
  at: destination,
@@ -87,4 +69,62 @@ final class ZipUtils {
87
69
  }
88
70
  }
89
71
  }
72
+
73
+ private func validateEntries(in archive: Archive, destination: URL) throws {
74
+ let destinationPath = destination.standardizedFileURL.path
75
+ let destinationPrefix =
76
+ destinationPath.hasSuffix("/") ? destinationPath : "\(destinationPath)/"
77
+ var fileCount = 0
78
+ var totalSize: UInt64 = 0
79
+
80
+ for entry in archive {
81
+ try validateEntry(
82
+ entry,
83
+ destination: destination,
84
+ destinationPath: destinationPath,
85
+ destinationPrefix: destinationPrefix
86
+ )
87
+ guard entry.type == .file else { continue }
88
+
89
+ fileCount += 1
90
+ if fileCount > maxFiles {
91
+ throw ZipUtilsError.fileCountExceeded(fileCount)
92
+ }
93
+ totalSize += entry.uncompressedSize
94
+ if totalSize > maxTotalSize {
95
+ throw ZipUtilsError.totalSizeExceeded(totalSize)
96
+ }
97
+ }
98
+ }
99
+
100
+ private func validateEntry(
101
+ _ entry: Entry,
102
+ destination: URL,
103
+ destinationPath: String,
104
+ destinationPrefix: String
105
+ ) throws {
106
+ if entry.path.hasPrefix("/") {
107
+ throw ZipUtilsError.absolutePath(entry.path)
108
+ }
109
+ if Self.containsParentDirectoryComponent(in: entry.path) {
110
+ throw ZipUtilsError.pathTraversal(entry.path)
111
+ }
112
+ if entry.type == .symlink {
113
+ throw ZipUtilsError.symlinkNotAllowed(entry.path)
114
+ }
115
+ if entry.type != .directory && entry.type != .file {
116
+ throw ZipUtilsError.unsupportedEntry(entry.path)
117
+ }
118
+
119
+ let outputPath = destination
120
+ .appendingPathComponent(entry.path)
121
+ .standardizedFileURL.path
122
+ if !(outputPath == destinationPath || outputPath.hasPrefix(destinationPrefix)) {
123
+ throw ZipUtilsError.pathTraversal(entry.path)
124
+ }
125
+ }
126
+
127
+ static func containsParentDirectoryComponent(in path: String) -> Bool {
128
+ path.split(separator: "/", omittingEmptySubsequences: false).contains("..")
129
+ }
90
130
  }
@@ -0,0 +1,227 @@
1
+ import Foundation
2
+ import XCTest
3
+ import ZIPFoundation
4
+ @testable import UpdaterPlugin
5
+
6
+ final class ZipUtilsTests: XCTestCase {
7
+ private struct EntrySpec {
8
+ let path: String
9
+ let type: Entry.EntryType
10
+ let data: Data
11
+
12
+ init(_ path: String, type: Entry.EntryType = .file, contents: String = "fixture") {
13
+ self.path = path
14
+ self.type = type
15
+ data = Data(contents.utf8)
16
+ }
17
+ }
18
+
19
+ private struct Fixture {
20
+ let root: URL
21
+ let archive: URL
22
+ let destination: URL
23
+
24
+ func cleanup() {
25
+ try? FileManager.default.removeItem(at: root)
26
+ }
27
+ }
28
+
29
+ func testExtractsYazlArchiveWithAdjacentDotsInsideFilename() throws {
30
+ // Generated by @otakit/cli's yazl 2.5.1 writer. Both entries use ZIP data
31
+ // descriptors, matching the archive shape reported in OtaKit issue #15.
32
+ let archiveData = try XCTUnwrap(Data(
33
+ base64Encoded: """
34
+ UEsDBBQACAgIAMSuG10AAAAAAAAAAAAAAAAKAAAAaW5kZXguaHRtbLNRTMlPLqksSFXI
35
+ KMnNsbMpySzJSbUrSi0oyrfRh3AAUEsHCFi8GEAgAAAAIwAAAFBLAwQUAAgICADE
36
+ rhtdAAAAAAAAAAAAAAAADgAAAHNhZmUuLmNodW5rLmpzS87PK87PSdXLyU/XUCpO
37
+ TEtV0gQAUEsHCJNQbzkVAAAAEwAAAFBLAQI/AxQACAgIAMSuG11YvBhAIAAAACMA
38
+ AAAKAAAAAAAAAAAAAACkgQAAAABpbmRleC5odG1sUEsBAj8DFAAICAgAxK4bXZNQ
39
+ bzkVAAAAEwAAAA4AAAAAAAAAAAAAAKSBWAAAAHNhZmUuLmNodW5rLmpzUEsFBgAA
40
+ AAACAAIAdAAAAKkAAAAAAA==
41
+ """,
42
+ options: .ignoreUnknownCharacters
43
+ ))
44
+ XCTAssertEqual(archiveData[6] & 0x08, 0x08, "Fixture must retain yazl's data descriptor flag")
45
+ let fixture = try makeFixture(archiveData: archiveData)
46
+ defer { fixture.cleanup() }
47
+
48
+ try ZipUtils().extractSecurely(zipURL: fixture.archive, to: fixture.destination)
49
+
50
+ XCTAssertEqual(
51
+ try String(
52
+ contentsOf: fixture.destination.appendingPathComponent("safe..chunk.js"),
53
+ encoding: .utf8
54
+ ),
55
+ "console.log(\"safe\")"
56
+ )
57
+ XCTAssertTrue(
58
+ FileManager.default.fileExists(
59
+ atPath: fixture.destination.appendingPathComponent("index.html").path
60
+ )
61
+ )
62
+ }
63
+
64
+ func testParentDirectoryCheckUsesPathComponentsNotSubstrings() {
65
+ let safePaths = [
66
+ "asset..js",
67
+ "...",
68
+ ".well-known/config.json",
69
+ "dir/..hidden/file.js",
70
+ "dir/name...js",
71
+ "dir/%2E%2E/file.js"
72
+ ]
73
+ for path in safePaths {
74
+ XCTAssertFalse(
75
+ ZipUtils.containsParentDirectoryComponent(in: path),
76
+ "Expected safe path: \(path)"
77
+ )
78
+ }
79
+
80
+ let unsafePaths = [
81
+ "..",
82
+ "../escape.js",
83
+ "dir/../escape.js",
84
+ "dir/..",
85
+ "/../escape.js",
86
+ "dir//../escape.js"
87
+ ]
88
+ for path in unsafePaths {
89
+ XCTAssertTrue(
90
+ ZipUtils.containsParentDirectoryComponent(in: path),
91
+ "Expected parent-directory path component: \(path)"
92
+ )
93
+ }
94
+ }
95
+
96
+ func testExtractsNestedUnicodeAndDotNamedFiles() throws {
97
+ let fixture = try makeFixture(entries: [
98
+ EntrySpec("index.html", contents: "home"),
99
+ EntrySpec("assets/.well-known/name...üñícode.js", contents: "payload"),
100
+ EntrySpec("assets/empty.txt", contents: "")
101
+ ])
102
+ defer { fixture.cleanup() }
103
+
104
+ try ZipUtils().extractSecurely(zipURL: fixture.archive, to: fixture.destination)
105
+
106
+ XCTAssertEqual(
107
+ try String(
108
+ contentsOf: fixture.destination
109
+ .appendingPathComponent("assets/.well-known/name...üñícode.js"),
110
+ encoding: .utf8
111
+ ),
112
+ "payload"
113
+ )
114
+ XCTAssertEqual(
115
+ try Data(contentsOf: fixture.destination.appendingPathComponent("assets/empty.txt")),
116
+ Data()
117
+ )
118
+ }
119
+
120
+ func testRejectsExactParentDirectorySegmentBeforeExtractingAnything() throws {
121
+ let fixture = try makeFixture(entries: [
122
+ EntrySpec("index.html", contents: "must not be written"),
123
+ EntrySpec("assets/../escape.js")
124
+ ])
125
+ defer { fixture.cleanup() }
126
+
127
+ XCTAssertThrowsError(
128
+ try ZipUtils().extractSecurely(zipURL: fixture.archive, to: fixture.destination)
129
+ ) { error in
130
+ guard case let ZipUtilsError.pathTraversal(path) = error else {
131
+ return XCTFail("Expected pathTraversal, got \(String(reflecting: error))")
132
+ }
133
+ XCTAssertEqual(path, "assets/../escape.js")
134
+ XCTAssertEqual(
135
+ error.localizedDescription,
136
+ "ZIP entry contains a parent-directory path component: assets/../escape.js"
137
+ )
138
+ }
139
+ XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.path))
140
+ }
141
+
142
+ func testRejectsAbsolutePathBeforeExtractingAnything() throws {
143
+ let fixture = try makeFixture(entries: [EntrySpec("/escape.js")])
144
+ defer { fixture.cleanup() }
145
+
146
+ XCTAssertThrowsError(
147
+ try ZipUtils().extractSecurely(zipURL: fixture.archive, to: fixture.destination)
148
+ ) { error in
149
+ guard case let ZipUtilsError.absolutePath(path) = error else {
150
+ return XCTFail("Expected absolutePath, got \(String(reflecting: error))")
151
+ }
152
+ XCTAssertEqual(path, "/escape.js")
153
+ }
154
+ XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.path))
155
+ }
156
+
157
+ func testRejectsSymbolicLinkBeforeExtractingAnything() throws {
158
+ let fixture = try makeFixture(entries: [
159
+ EntrySpec("index.html", contents: "must not be written"),
160
+ EntrySpec("link", type: .symlink, contents: "../outside")
161
+ ])
162
+ defer { fixture.cleanup() }
163
+
164
+ XCTAssertThrowsError(
165
+ try ZipUtils().extractSecurely(zipURL: fixture.archive, to: fixture.destination)
166
+ ) { error in
167
+ guard case let ZipUtilsError.symlinkNotAllowed(path) = error else {
168
+ return XCTFail("Expected symlinkNotAllowed, got \(String(reflecting: error))")
169
+ }
170
+ XCTAssertEqual(path, "link")
171
+ }
172
+ XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.path))
173
+ }
174
+
175
+ func testRejectsUnreadableArchiveWithClearError() throws {
176
+ let fixture = try makeFixture(archiveData: Data("not a zip".utf8))
177
+ defer { fixture.cleanup() }
178
+
179
+ XCTAssertThrowsError(
180
+ try ZipUtils().extractSecurely(zipURL: fixture.archive, to: fixture.destination)
181
+ ) { error in
182
+ guard case ZipUtilsError.invalidZip = error else {
183
+ return XCTFail("Expected invalidZip, got \(String(reflecting: error))")
184
+ }
185
+ XCTAssertEqual(
186
+ error.localizedDescription,
187
+ "Downloaded bundle is not a readable ZIP archive"
188
+ )
189
+ }
190
+ XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.path))
191
+ }
192
+
193
+ private func makeFixture(archiveData: Data) throws -> Fixture {
194
+ let fixture = try emptyFixture()
195
+ try archiveData.write(to: fixture.archive)
196
+ return fixture
197
+ }
198
+
199
+ private func makeFixture(entries: [EntrySpec]) throws -> Fixture {
200
+ let fixture = try emptyFixture()
201
+ let archive = try Archive(url: fixture.archive, accessMode: .create, pathEncoding: nil)
202
+ for entry in entries {
203
+ try archive.addEntry(
204
+ with: entry.path,
205
+ type: entry.type,
206
+ uncompressedSize: Int64(entry.data.count),
207
+ compressionMethod: entry.type == .file ? .deflate : .none
208
+ ) { position, size in
209
+ let lowerBound = Int(position)
210
+ let upperBound = min(lowerBound + size, entry.data.count)
211
+ return entry.data.subdata(in: lowerBound..<upperBound)
212
+ }
213
+ }
214
+ return fixture
215
+ }
216
+
217
+ private func emptyFixture() throws -> Fixture {
218
+ let root = FileManager.default.temporaryDirectory
219
+ .appendingPathComponent("otakit-zip-tests-\(UUID().uuidString)", isDirectory: true)
220
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
221
+ return Fixture(
222
+ root: root,
223
+ archive: root.appendingPathComponent("fixture.zip"),
224
+ destination: root.appendingPathComponent("extracted", isDirectory: true)
225
+ )
226
+ }
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",