@capgo/capacitor-updater 8.51.21 → 8.51.22

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.
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.21";
149
+ private final String pluginVersion = "8.51.22";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -285,6 +285,15 @@ public class CapgoUpdater {
285
285
  this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
286
286
  }
287
287
 
288
+ static boolean containsPathTraversalSegment(final String relativePath) {
289
+ for (final String segment : relativePath.split("/")) {
290
+ if ("..".equals(segment)) {
291
+ return true;
292
+ }
293
+ }
294
+ return false;
295
+ }
296
+
288
297
  static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
289
298
  if (relativePath == null || relativePath.isEmpty()) {
290
299
  throw new IOException("Invalid empty path");
@@ -292,6 +301,9 @@ public class CapgoUpdater {
292
301
  if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
293
302
  throw new IOException("Invalid path separator");
294
303
  }
304
+ if (containsPathTraversalSegment(relativePath)) {
305
+ throw new IOException("Path traversal segments are not allowed");
306
+ }
295
307
  if (new File(relativePath).isAbsolute()) {
296
308
  throw new IOException("Absolute paths are not allowed");
297
309
  }
@@ -302,7 +314,8 @@ public class CapgoUpdater {
302
314
  final String targetPath = canonicalTarget.getPath();
303
315
  final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
304
316
 
305
- if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
317
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
318
+ if (!targetPath.startsWith(normalizedBasePath)) {
306
319
  throw new IOException("Path escapes base directory: " + relativePath);
307
320
  }
308
321
 
@@ -50,17 +50,15 @@ public class CryptoCipher {
50
50
  return decryptedBytes;
51
51
  }
52
52
 
53
- public static byte[] decryptAES(byte[] cipherText, SecretKey key, byte[] iv) {
54
- try {
55
- IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
56
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
57
- SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
58
- cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
59
- return cipher.doFinal(cipherText);
60
- } catch (Exception e) {
61
- e.printStackTrace();
53
+ public static byte[] decryptAES(byte[] cipherText, SecretKey key, byte[] iv) throws GeneralSecurityException {
54
+ if (key == null || iv == null) {
55
+ throw new IllegalArgumentException("AES key and IV must not be null");
62
56
  }
63
- return null;
57
+ IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
58
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
59
+ SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
60
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
61
+ return cipher.doFinal(cipherText);
64
62
  }
65
63
 
66
64
  public static SecretKey byteToSessionKey(byte[] sessionKey) {
@@ -139,11 +137,15 @@ public class CryptoCipher {
139
137
 
140
138
  public static void decryptFile(final File file, final String publicKey, final String ivSessionKey) throws IOException {
141
139
  if (publicKey.isEmpty() || ivSessionKey == null || ivSessionKey.isEmpty() || ivSessionKey.split(":").length != 2) {
142
- logger.info("Encryption not set, no public key or session, ignored");
140
+ if (logger != null) {
141
+ logger.info("Encryption not set, no public key or session, ignored");
142
+ }
143
143
  return;
144
144
  }
145
145
  if (!publicKey.startsWith("-----BEGIN RSA PUBLIC KEY-----")) {
146
- logger.error("The public key is not a valid RSA Public key");
146
+ if (logger != null) {
147
+ logger.error("The public key is not a valid RSA Public key");
148
+ }
147
149
  return;
148
150
  }
149
151
 
@@ -152,29 +154,50 @@ public class CryptoCipher {
152
154
  String sessionKeyB64 = ivSessionKey.split(":")[1];
153
155
  byte[] iv = Base64.decode(ivB64.getBytes(), Base64.DEFAULT);
154
156
  byte[] sessionKey = Base64.decode(sessionKeyB64.getBytes(), Base64.DEFAULT);
157
+ if (iv.length != 16) {
158
+ throw new IOException("AES file decryption failed: IV must be 16 bytes");
159
+ }
155
160
  PublicKey pKey = CryptoCipher.stringToPublicKey(publicKey);
156
161
  byte[] decryptedSessionKey = CryptoCipher.decryptRSA(sessionKey, pKey);
162
+ if (decryptedSessionKey == null || decryptedSessionKey.length != 16) {
163
+ throw new IOException("AES file decryption failed: decrypted session key must be 16 bytes");
164
+ }
157
165
 
158
166
  SecretKey sKey = CryptoCipher.byteToSessionKey(decryptedSessionKey);
159
167
  decryptAesFile(file, sKey, iv);
160
168
  } catch (GeneralSecurityException e) {
161
- logger.info("decryptFile fail");
162
- throw new IOException("GeneralSecurityException", e);
169
+ if (logger != null) {
170
+ logger.info("decryptFile fail");
171
+ }
172
+ throw new IOException("AES file decryption failed: " + e.getMessage(), e);
163
173
  }
164
174
  }
165
175
 
166
- static void decryptAesFile(File file, SecretKey key, byte[] iv) throws IOException, GeneralSecurityException {
176
+ static void decryptAesFile(File file, SecretKey key, byte[] iv) throws IOException {
177
+ if (key == null) {
178
+ throw new IOException("AES file decryption failed: missing session key");
179
+ }
180
+ if (iv == null) {
181
+ throw new IOException("AES file decryption failed: missing IV");
182
+ }
183
+ if (iv.length != 16) {
184
+ throw new IOException("AES file decryption failed: IV must be 16 bytes");
185
+ }
186
+ byte[] keyBytes = key.getEncoded();
187
+ if (keyBytes == null || keyBytes.length != 16) {
188
+ throw new IOException("AES file decryption failed: session key must be 16 bytes");
189
+ }
167
190
  if (file.length() == 0) {
168
191
  throw new IOException("Empty encrypted data");
169
192
  }
170
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
171
- cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getEncoded(), "AES"), new IvParameterSpec(iv));
172
193
  File parent = file.getAbsoluteFile().getParentFile();
173
194
  if (parent == null) {
174
195
  throw new IOException("Cannot create temp file for " + file.getAbsolutePath());
175
196
  }
176
197
  File tempFile = File.createTempFile("capgo-aes-", ".tmp", parent);
177
198
  try {
199
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
200
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new IvParameterSpec(iv));
178
201
  byte[] inBuf = new byte[ioBufferBytes()];
179
202
  // Reuse one output buffer. cipher.update(in) allocates a new byte[] per chunk.
180
203
  byte[] outBuf = new byte[inBuf.length + 16];
@@ -196,6 +219,8 @@ public class CryptoCipher {
196
219
  }
197
220
  replaceFile(tempFile, file);
198
221
  tempFile = null;
222
+ } catch (GeneralSecurityException e) {
223
+ throw new IOException("AES file decryption failed: " + e.getMessage(), e);
199
224
  } finally {
200
225
  if (tempFile != null && tempFile.exists()) {
201
226
  tempFile.delete();
@@ -363,6 +363,38 @@ public class DownloadService extends Worker {
363
363
  return seenTargets.add(targetFile.getCanonicalPath());
364
364
  }
365
365
 
366
+ private static final class ManifestDownloadTask {
367
+
368
+ final String fileName;
369
+ final String finalFileHash;
370
+ final String downloadUrl;
371
+ final boolean isBrotli;
372
+ final File targetFile;
373
+ final File builtinFile;
374
+ final File cacheFile;
375
+ final File legacyCacheFile;
376
+
377
+ ManifestDownloadTask(
378
+ final String fileName,
379
+ final String finalFileHash,
380
+ final String downloadUrl,
381
+ final boolean isBrotli,
382
+ final File targetFile,
383
+ final File builtinFile,
384
+ final File cacheFile,
385
+ final File legacyCacheFile
386
+ ) {
387
+ this.fileName = fileName;
388
+ this.finalFileHash = finalFileHash;
389
+ this.downloadUrl = downloadUrl;
390
+ this.isBrotli = isBrotli;
391
+ this.targetFile = targetFile;
392
+ this.builtinFile = builtinFile;
393
+ this.cacheFile = cacheFile;
394
+ this.legacyCacheFile = legacyCacheFile;
395
+ }
396
+ }
397
+
366
398
  static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
367
399
  if (assets == null || fileName == null || dest == null) {
368
400
  return false;
@@ -535,9 +567,7 @@ public class DownloadService extends Worker {
535
567
  int totalFiles = manifest.length();
536
568
  final AtomicLong completedFiles = new AtomicLong(0);
537
569
  final AtomicBoolean hasError = new AtomicBoolean(false);
538
-
539
- ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
540
- List<Future<?>> futures = new ArrayList<>();
570
+ final List<ManifestDownloadTask> tasks = new ArrayList<>();
541
571
  final Set<String> seenTargets = new HashSet<>();
542
572
 
543
573
  for (int i = 0; i < totalFiles; i++) {
@@ -592,35 +622,56 @@ public class DownloadService extends Worker {
592
622
  final File legacyCacheFile =
593
623
  isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
594
624
 
595
- // Ensure parent directories of the target file exist
596
- if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
597
- logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
598
- hasError.set(true);
599
- continue;
625
+ tasks.add(
626
+ new ManifestDownloadTask(
627
+ fileName,
628
+ finalFileHash,
629
+ downloadUrl,
630
+ isBrotli,
631
+ targetFile,
632
+ builtinFile,
633
+ cacheFile,
634
+ legacyCacheFile
635
+ )
636
+ );
637
+ }
638
+
639
+ if (hasError.get()) {
640
+ throw new IOException("Manifest contains invalid or duplicate file paths");
641
+ }
642
+
643
+ for (final ManifestDownloadTask task : tasks) {
644
+ if (!Objects.requireNonNull(task.targetFile.getParentFile()).exists() && !task.targetFile.getParentFile().mkdirs()) {
645
+ logger.error("Failed to create parent directory for: " + task.targetFile.getAbsolutePath());
646
+ throw new IOException("Failed to create parent directory for: " + task.targetFile.getAbsolutePath());
600
647
  }
648
+ }
649
+
650
+ ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
651
+ List<Future<?>> futures = new ArrayList<>();
601
652
 
602
- final boolean finalIsBrotli = isBrotli;
653
+ for (final ManifestDownloadTask task : tasks) {
603
654
  Future<?> future = executor.submit(() -> {
604
655
  try {
605
- if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
606
- logger.debug("using builtin asset " + fileName);
607
- } else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
608
- logger.debug("using builtin file " + fileName);
656
+ if (tryCopyBuiltinAsset(assets, task.fileName, task.targetFile, task.finalFileHash)) {
657
+ logger.debug("using builtin asset " + task.fileName);
658
+ } else if (tryCopyBuiltinFile(task.builtinFile, task.targetFile, task.finalFileHash)) {
659
+ logger.debug("using builtin file " + task.fileName);
609
660
  } else if (
610
- tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
611
- (legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
661
+ tryCopyFromCache(task.cacheFile, task.targetFile, task.finalFileHash) ||
662
+ (task.legacyCacheFile != null && tryCopyFromCache(task.legacyCacheFile, task.targetFile, task.finalFileHash))
612
663
  ) {
613
- logger.debug("already cached " + fileName);
664
+ logger.debug("already cached " + task.fileName);
614
665
  } else {
615
666
  downloadAndVerify(
616
- downloadUrl,
617
- targetFile,
618
- cacheFile,
619
- finalFileHash,
667
+ task.downloadUrl,
668
+ task.targetFile,
669
+ task.cacheFile,
670
+ task.finalFileHash,
620
671
  sessionKey,
621
672
  publicKey,
622
- finalIsBrotli,
623
- fileName
673
+ task.isBrotli,
674
+ task.fileName
624
675
  );
625
676
  }
626
677
 
@@ -628,8 +679,8 @@ public class DownloadService extends Worker {
628
679
  int percent = calcTotalPercent(completed, totalFiles);
629
680
  setProgress(percent);
630
681
  } catch (Exception e) {
631
- logger.error("Error processing file: " + fileName + " " + e.getMessage());
632
- sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
682
+ logger.error("Error processing file: " + task.fileName + " " + e.getMessage());
683
+ sendStatsAsync("download_manifest_file_fail", version + ":" + task.fileName);
633
684
  hasError.set(true);
634
685
  }
635
686
  });
@@ -96,7 +96,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
96
  deinit {
97
97
  implementation.shutdown()
98
98
  }
99
- private let pluginVersion: String = "8.51.21"
99
+ private let pluginVersion: String = "8.51.22"
100
100
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
101
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
102
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -3496,13 +3496,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3496
3496
  }
3497
3497
 
3498
3498
  private func performHideSplashscreen() {
3499
- self.cancelSplashscreenTimeout()
3500
3499
  self.removeSplashscreenLoader()
3501
3500
  self.splashscreenInvocationToken += 1
3502
- self.invokeSplashscreenMethod(
3503
- methodName: "hide",
3504
- callbackId: "autoHideSplashscreen",
3505
- options: self.splashscreenOptions(methodName: "hide"),
3501
+ self.invokeSplashscreenAction(
3502
+ .hide,
3506
3503
  retriesRemaining: self.splashscreenMaxRetries,
3507
3504
  requestToken: self.splashscreenInvocationToken
3508
3505
  )
@@ -3522,10 +3519,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3522
3519
  self.cancelSplashscreenTimeout()
3523
3520
  self.autoSplashscreenTimedOut = false
3524
3521
  self.splashscreenInvocationToken += 1
3525
- self.invokeSplashscreenMethod(
3526
- methodName: "show",
3527
- callbackId: "autoShowSplashscreen",
3528
- options: self.splashscreenOptions(methodName: "show"),
3522
+ self.invokeSplashscreenAction(
3523
+ .show,
3529
3524
  retriesRemaining: self.splashscreenMaxRetries,
3530
3525
  requestToken: self.splashscreenInvocationToken
3531
3526
  )
@@ -3535,7 +3530,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3535
3530
  }
3536
3531
 
3537
3532
  private func splashscreenOptions(methodName: String) -> [String: Any] {
3538
- methodName == "show" ? ["autoHide": false] : [:]
3533
+ methodName == "show" ? ["autoHide": false, "fadeInDuration": 0] : [:]
3539
3534
  }
3540
3535
 
3541
3536
  private func splashscreenCompletedMessage(methodName: String) -> String {
@@ -3554,20 +3549,59 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3554
3549
  self.splashscreenInvocationToken += 1
3555
3550
  }
3556
3551
 
3557
- private func makeSplashscreenCall(callbackId: String, options: [String: Any], methodName: String) -> CAPPluginCall {
3558
- CAPPluginCall(callbackId: callbackId, methodName: methodName, options: options, success: { [weak self] (_, _) in
3559
- guard let self = self else { return }
3560
- self.logger.info(self.splashscreenCompletedMessage(methodName: methodName))
3561
- }, error: { [weak self] (_) in
3562
- guard let self = self else { return }
3563
- self.logger.error("Failed to auto-\(methodName) splashscreen")
3564
- })
3552
+ private enum SplashscreenAction {
3553
+ case show
3554
+ case hide
3555
+
3556
+ var methodName: String {
3557
+ switch self {
3558
+ case .show:
3559
+ return "show"
3560
+ case .hide:
3561
+ return "hide"
3562
+ }
3563
+ }
3564
+
3565
+ var unavailableMessageVerb: String {
3566
+ switch self {
3567
+ case .show:
3568
+ return "showing"
3569
+ case .hide:
3570
+ return "hiding"
3571
+ }
3572
+ }
3573
+ }
3574
+
3575
+ private func splashscreenOptionsJSON(methodName: String) -> String {
3576
+ let options = self.splashscreenOptions(methodName: methodName)
3577
+ guard !options.isEmpty,
3578
+ let data = try? JSONSerialization.data(withJSONObject: options),
3579
+ let json = String(data: data, encoding: .utf8) else {
3580
+ return "{}"
3581
+ }
3582
+ return json
3583
+ }
3584
+
3585
+ private func splashscreenPluginExposesMethod(_ methodName: String, on plugin: CAPPlugin) -> Bool {
3586
+ guard let bridgedPlugin = plugin as? CAPBridgedPlugin else {
3587
+ return false
3588
+ }
3589
+ return bridgedPlugin.pluginMethods.contains { $0.name == methodName }
3590
+ }
3591
+
3592
+ private func splashscreenBridgeScript(for action: SplashscreenAction) -> String {
3593
+ let optionsJSON = self.splashscreenOptionsJSON(methodName: action.methodName)
3594
+ return """
3595
+ var cap = window.Capacitor;
3596
+ if (!cap || typeof cap.nativePromise !== 'function') {
3597
+ throw new Error('Capacitor bridge not ready');
3598
+ }
3599
+ return await cap.nativePromise('\(self.splashscreenPluginName)', '\(action.methodName)', \(optionsJSON));
3600
+ """
3565
3601
  }
3566
3602
 
3567
- private func invokeSplashscreenMethod(
3568
- methodName: String,
3569
- callbackId: String,
3570
- options: [String: Any],
3603
+ private func invokeSplashscreenAction(
3604
+ _ action: SplashscreenAction,
3571
3605
  retriesRemaining: Int,
3572
3606
  requestToken: Int
3573
3607
  ) {
@@ -3576,22 +3610,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3576
3610
  }
3577
3611
 
3578
3612
  guard let bridge = self.bridge else {
3579
- self.retrySplashscreenMethod(
3580
- methodName: methodName,
3581
- callbackId: callbackId,
3582
- options: options,
3613
+ self.retrySplashscreenAction(
3614
+ action,
3583
3615
  retriesRemaining: retriesRemaining,
3584
3616
  requestToken: requestToken,
3585
- message: "Bridge not available for \(methodName == "show" ? "showing" : "hiding") splashscreen with autoSplashscreen"
3617
+ message: "Bridge not available for \(action.unavailableMessageVerb) splashscreen with autoSplashscreen"
3586
3618
  )
3587
3619
  return
3588
3620
  }
3589
3621
 
3590
3622
  guard let splashScreenPlugin = bridge.plugin(withName: self.splashscreenPluginName) else {
3591
- self.retrySplashscreenMethod(
3592
- methodName: methodName,
3593
- callbackId: callbackId,
3594
- options: options,
3623
+ self.retrySplashscreenAction(
3624
+ action,
3595
3625
  retriesRemaining: retriesRemaining,
3596
3626
  requestToken: requestToken,
3597
3627
  message: "autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin."
@@ -3599,37 +3629,86 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3599
3629
  return
3600
3630
  }
3601
3631
 
3602
- let selector = NSSelectorFromString("\(methodName):")
3603
- guard splashScreenPlugin.responds(to: selector) else {
3604
- self.retrySplashscreenMethod(
3605
- methodName: methodName,
3606
- callbackId: callbackId,
3607
- options: options,
3632
+ guard self.splashscreenPluginExposesMethod(action.methodName, on: splashScreenPlugin) else {
3633
+ self.retrySplashscreenAction(
3634
+ action,
3635
+ retriesRemaining: retriesRemaining,
3636
+ requestToken: requestToken,
3637
+ message: "autoSplashscreen: SplashScreen plugin does not expose \(action.methodName)(). Make sure @capacitor/splash-screen plugin is properly installed."
3638
+ )
3639
+ return
3640
+ }
3641
+
3642
+ guard let webView = bridge.webView else {
3643
+ self.retrySplashscreenAction(
3644
+ action,
3608
3645
  retriesRemaining: retriesRemaining,
3609
3646
  requestToken: requestToken,
3610
- message: "autoSplashscreen: SplashScreen plugin does not respond to \(methodName): method. Make sure @capacitor/splash-screen plugin is properly installed."
3647
+ message: "WebView not available for \(action.unavailableMessageVerb) splashscreen with autoSplashscreen"
3611
3648
  )
3612
3649
  return
3613
3650
  }
3614
3651
 
3615
- let call = self.makeSplashscreenCall(callbackId: callbackId, options: options, methodName: methodName)
3616
- _ = splashScreenPlugin.perform(selector, with: call)
3617
- self.logger.info("Called SplashScreen \(methodName) method")
3652
+ let script = self.splashscreenBridgeScript(for: action)
3653
+ let runInvocation = { [weak self] in
3654
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3655
+ return
3656
+ }
3657
+ webView.callAsyncJavaScript(
3658
+ script,
3659
+ arguments: [:],
3660
+ in: nil,
3661
+ in: .page
3662
+ ) { [weak self] (result: Result<Any, Error>) in
3663
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3664
+ return
3665
+ }
3666
+ if case .failure(let error) = result {
3667
+ self.retrySplashscreenAction(
3668
+ action,
3669
+ retriesRemaining: retriesRemaining,
3670
+ requestToken: requestToken,
3671
+ message: "Failed to invoke SplashScreen \(action.methodName) via Capacitor bridge: \(error.localizedDescription)"
3672
+ )
3673
+ return
3674
+ }
3675
+ if action == .hide {
3676
+ self.cancelSplashscreenTimeout()
3677
+ }
3678
+ self.logger.info("Called SplashScreen \(action.methodName) method")
3679
+ self.logger.info(self.splashscreenCompletedMessage(methodName: action.methodName))
3680
+ }
3681
+ }
3682
+
3683
+ if Thread.isMainThread {
3684
+ runInvocation()
3685
+ } else {
3686
+ DispatchQueue.main.async(execute: runInvocation)
3687
+ }
3618
3688
  }
3619
3689
 
3620
- private func retrySplashscreenMethod(
3621
- methodName: String,
3622
- callbackId: String,
3623
- options: [String: Any],
3690
+ private func retrySplashscreenAction(
3691
+ _ action: SplashscreenAction,
3624
3692
  retriesRemaining: Int,
3625
3693
  requestToken: Int,
3626
3694
  message: String
3627
3695
  ) {
3628
3696
  guard retriesRemaining > 0 else {
3629
- if methodName == "show" {
3697
+ if action == .show {
3630
3698
  self.logger.warn(message)
3631
- } else {
3632
- self.logger.error(message)
3699
+ return
3700
+ }
3701
+
3702
+ self.logger.error("\(message). Scheduling another hide retry.")
3703
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.splashscreenRetryDelayMilliseconds)) { [weak self] in
3704
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3705
+ return
3706
+ }
3707
+ self.invokeSplashscreenAction(
3708
+ action,
3709
+ retriesRemaining: self.splashscreenMaxRetries,
3710
+ requestToken: requestToken
3711
+ )
3633
3712
  }
3634
3713
  return
3635
3714
  }
@@ -3639,10 +3718,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3639
3718
  guard let self = self, requestToken == self.splashscreenInvocationToken else {
3640
3719
  return
3641
3720
  }
3642
- self.invokeSplashscreenMethod(
3643
- methodName: methodName,
3644
- callbackId: callbackId,
3645
- options: options,
3721
+ self.invokeSplashscreenAction(
3722
+ action,
3646
3723
  retriesRemaining: retriesRemaining - 1,
3647
3724
  requestToken: requestToken
3648
3725
  )
@@ -134,6 +134,10 @@ import UIKit
134
134
  case pathTraversal
135
135
  }
136
136
 
137
+ static func containsPathTraversalSegment(_ relativePath: String) -> Bool {
138
+ return relativePath.split(separator: "/").contains(where: { $0 == ".." })
139
+ }
140
+
137
141
  static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
138
142
  if relativePath.isEmpty {
139
143
  throw SecurePathError.emptyPath
@@ -141,6 +145,9 @@ import UIKit
141
145
  if relativePath.contains("\\") || relativePath.contains("\0") {
142
146
  throw SecurePathError.windowsPath
143
147
  }
148
+ if containsPathTraversalSegment(relativePath) {
149
+ throw SecurePathError.pathTraversal
150
+ }
144
151
  if (relativePath as NSString).isAbsolutePath {
145
152
  throw SecurePathError.absolutePath
146
153
  }
@@ -151,7 +158,8 @@ import UIKit
151
158
  let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
152
159
  let canonicalTargetPath = canonicalTarget.path
153
160
 
154
- if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
161
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
162
+ if !canonicalTargetPath.hasPrefix(normalizedBasePath) {
155
163
  throw SecurePathError.pathTraversal
156
164
  }
157
165
 
@@ -164,6 +172,22 @@ import UIKit
164
172
  return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
165
173
  }
166
174
 
175
+ static func rememberManifestTarget(_ seenTargets: inout Set<String>, targetFile: URL) -> Bool {
176
+ return seenTargets.insert(targetFile.standardizedFileURL.path).inserted
177
+ }
178
+
179
+ private struct ManifestDownloadTask {
180
+ let fileName: String
181
+ let downloadUrl: String
182
+ let finalFileHash: String
183
+ let isBrotli: Bool
184
+ let destFileName: String
185
+ let destFilePath: URL
186
+ let builtinFilePath: URL
187
+ let cacheFilePath: URL?
188
+ let legacyCacheFilePath: URL?
189
+ }
190
+
167
191
  private func isTimedOutError(_ error: Error?) -> Bool {
168
192
  guard let nsError = error as NSError? else {
169
193
  return false
@@ -1416,8 +1440,8 @@ import UIKit
1416
1440
  var downloadError: Error?
1417
1441
  let errorLock = NSLock()
1418
1442
 
1419
- // Create operations for each file
1420
- var operations: [Operation] = []
1443
+ var tasks: [ManifestDownloadTask] = []
1444
+ var seenTargets = Set<String>()
1421
1445
 
1422
1446
  for entry in manifest {
1423
1447
  guard let fileName = entry.file_name,
@@ -1489,6 +1513,24 @@ import UIKit
1489
1513
  do {
1490
1514
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1491
1515
  builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1516
+ if !Self.rememberManifestTarget(&seenTargets, targetFile: destFilePath) {
1517
+ logger.error("Duplicate manifest target path: \(fileName)")
1518
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1519
+ let error = NSError(
1520
+ domain: "ManifestEntryError",
1521
+ code: 3,
1522
+ userInfo: [
1523
+ NSLocalizedDescriptionKey: "Duplicate manifest target path for \(fileName)"
1524
+ ]
1525
+ )
1526
+ errorLock.lock()
1527
+ if downloadError == nil {
1528
+ downloadError = error
1529
+ }
1530
+ errorLock.unlock()
1531
+ hasError.value = true
1532
+ continue
1533
+ }
1492
1534
  } catch {
1493
1535
  logger.error("Invalid manifest file path: \(fileName)")
1494
1536
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1501,8 +1543,40 @@ import UIKit
1501
1543
  continue
1502
1544
  }
1503
1545
 
1504
- // Create parent directories synchronously (before operations start)
1505
- try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1546
+ tasks.append(
1547
+ ManifestDownloadTask(
1548
+ fileName: fileName,
1549
+ downloadUrl: downloadUrl,
1550
+ finalFileHash: finalFileHash,
1551
+ isBrotli: isBrotli,
1552
+ destFileName: destFileName,
1553
+ destFilePath: destFilePath,
1554
+ builtinFilePath: builtinFilePath,
1555
+ cacheFilePath: cacheFilePath,
1556
+ legacyCacheFilePath: legacyCacheFilePath
1557
+ )
1558
+ )
1559
+ }
1560
+
1561
+ if hasError.value {
1562
+ let resolvedError = downloadError ?? NSError(
1563
+ domain: "ManifestDownloadError",
1564
+ code: 1,
1565
+ userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
1566
+ )
1567
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
1568
+ self.saveBundleInfo(id: id, bundle: errorBundle)
1569
+ throw resolvedError
1570
+ }
1571
+
1572
+ var operations: [Operation] = []
1573
+
1574
+ for task in tasks {
1575
+ try FileManager.default.createDirectory(
1576
+ at: task.destFilePath.deletingLastPathComponent(),
1577
+ withIntermediateDirectories: true,
1578
+ attributes: nil
1579
+ )
1506
1580
 
1507
1581
  let operation = BlockOperation { [weak self] in
1508
1582
  guard let self = self else { return }
@@ -1510,26 +1584,29 @@ import UIKit
1510
1584
 
1511
1585
  do {
1512
1586
  // Try builtin first
1513
- if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
1514
- try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
1515
- self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
1587
+ if FileManager.default.fileExists(atPath: task.builtinFilePath.path) &&
1588
+ self.verifyChecksum(file: task.builtinFilePath, expectedHash: task.finalFileHash) {
1589
+ try self.copyItemReplacing(from: task.builtinFilePath, to: task.destFilePath)
1590
+ self.logger.info("downloadManifest \(task.fileName) using builtin file \(id)")
1516
1591
  }
1517
1592
  // Try cache
1518
1593
  else if
1519
- (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1520
- (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1521
- self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1594
+ (task.cacheFilePath != nil &&
1595
+ self.tryCopyFromCache(from: task.cacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) ||
1596
+ (task.legacyCacheFilePath != nil &&
1597
+ self.tryCopyFromCache(from: task.legacyCacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) {
1598
+ self.logger.info("downloadManifest \(task.fileName) copy from cache \(id)")
1522
1599
  }
1523
1600
  // Download
1524
1601
  else {
1525
1602
  try self.downloadManifestFile(
1526
- downloadUrl: downloadUrl,
1527
- destFilePath: destFilePath,
1528
- cacheFilePath: cacheFilePath,
1529
- fileHash: finalFileHash,
1530
- fileName: fileName,
1531
- destFileName: destFileName,
1532
- isBrotli: isBrotli,
1603
+ downloadUrl: task.downloadUrl,
1604
+ destFilePath: task.destFilePath,
1605
+ cacheFilePath: task.cacheFilePath,
1606
+ fileHash: task.finalFileHash,
1607
+ fileName: task.fileName,
1608
+ destFileName: task.destFileName,
1609
+ isBrotli: task.isBrotli,
1533
1610
  sessionKey: sessionKey,
1534
1611
  version: version,
1535
1612
  bundleId: id
@@ -1547,8 +1624,8 @@ import UIKit
1547
1624
  }
1548
1625
  errorLock.unlock()
1549
1626
  hasError.value = true
1550
- self.logger.error("Manifest file download failed: \(fileName)")
1551
- self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
1627
+ self.logger.error("Manifest file download failed: \(task.fileName)")
1628
+ self.logger.debug("Bundle: \(id), File: \(task.fileName), Error: \(error.localizedDescription)")
1552
1629
  }
1553
1630
  }
1554
1631
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.21",
3
+ "version": "8.51.22",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",