@capgo/cordova-updater 8.1.4 → 8.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/definitions.d.ts +116 -86
- package/dist/esm/definitions.js +2 -2
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/exec.js +11 -1
- package/dist/esm/exec.js.map +1 -1
- package/dist/esm/history.js +7 -7
- package/dist/esm/history.js.map +1 -1
- package/dist/plugin.cjs.js +13 -3
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +13 -3
- package/dist/plugin.js.map +1 -1
- package/package.json +5 -3
- package/plugin.xml +9 -9
- package/src/android/CordovaUpdaterPlugin.java +173 -5
- package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
- package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
- package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
- package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
- package/src/android/app/capgo/cordova/updater/Version.java +312 -0
- package/src/android/capgo-cordova-updater.gradle +3 -5
- package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
- package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
- package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
- package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
- package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
- package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
- package/src/ios/CapgoSemanticVersion.swift +181 -0
- package/src/ios/CapgoUpdater.swift +427 -114
- package/src/ios/CordovaUpdaterPlugin.swift +171 -40
- package/src/ios/CryptoCipher.swift +41 -38
- package/src/ios/DelayUpdateUtils.swift +19 -45
- package/src/ios/Logger.swift +81 -5
- package/src/ios/RedirectPolicyDelegate.swift +53 -0
- package/src/ios/WebViewStatsReporter.swift +5 -0
- package/src/ios/ZipArchiveReader.swift +263 -0
- package/src/ios/ZipCentralDirectory.swift +283 -0
|
@@ -23,8 +23,6 @@ import androidx.work.OneTimeWorkRequest;
|
|
|
23
23
|
import androidx.work.PeriodicWorkRequest;
|
|
24
24
|
import androidx.work.WorkInfo;
|
|
25
25
|
import androidx.work.WorkManager;
|
|
26
|
-
import com.google.common.util.concurrent.Futures;
|
|
27
|
-
import com.google.common.util.concurrent.ListenableFuture;
|
|
28
26
|
import java.io.BufferedInputStream;
|
|
29
27
|
import java.io.BufferedReader;
|
|
30
28
|
import java.io.File;
|
|
@@ -152,6 +150,9 @@ public class CapgoUpdater {
|
|
|
152
150
|
}
|
|
153
151
|
|
|
154
152
|
private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
|
|
153
|
+
// Versions whose WorkManager download is observed by this process. WorkManager persists work across a
|
|
154
|
+
// process kill, but the observer that finishes the download does not survive it.
|
|
155
|
+
private final Set<String> observedDownloadVersions = ConcurrentHashMap.newKeySet();
|
|
155
156
|
private final ExecutorService io = Executors.newSingleThreadExecutor();
|
|
156
157
|
|
|
157
158
|
public CapgoUpdater(Logger logger) {
|
|
@@ -257,8 +258,53 @@ public class CapgoUpdater {
|
|
|
257
258
|
|
|
258
259
|
void directUpdateFinish(final BundleInfo latest) {}
|
|
259
260
|
|
|
261
|
+
/** Launch downloads have no waiter. The plugin emits appReady from here when WorkManager settles. */
|
|
262
|
+
void backgroundDownloadSettled(final BundleInfo bundle, final String status, final long settlementToken) {}
|
|
263
|
+
|
|
264
|
+
private void settleBackgroundDownload(final BundleInfo bundle, final String status, final long settlementToken) {
|
|
265
|
+
this.backgroundDownloadSettled(bundle, status, settlementToken);
|
|
266
|
+
}
|
|
267
|
+
|
|
260
268
|
void notifyListeners(final String id, final Map<String, Object> res) {}
|
|
261
269
|
|
|
270
|
+
static boolean shouldNotifyLaunchDownloadReady(
|
|
271
|
+
final boolean awaitedByCaller,
|
|
272
|
+
final boolean success,
|
|
273
|
+
final boolean directInstall,
|
|
274
|
+
final boolean previewSession
|
|
275
|
+
) {
|
|
276
|
+
if (awaitedByCaller) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (!success) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return !directInstall && !previewSession;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
static boolean shouldRestartOrphanedDownload(final boolean observedByThisProcess) {
|
|
286
|
+
return !observedByThisProcess;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
static boolean shouldReleaseLaunchWhileRetrying(
|
|
290
|
+
final boolean awaitedByCaller,
|
|
291
|
+
final boolean setNext,
|
|
292
|
+
final boolean directUpdate,
|
|
293
|
+
final boolean previewSession
|
|
294
|
+
) {
|
|
295
|
+
return !awaitedByCaller && setNext && directUpdate && !previewSession;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
static String launchDownloadReadyStatus(final boolean success, final boolean setNext) {
|
|
299
|
+
if (!success) {
|
|
300
|
+
return "Error downloading file";
|
|
301
|
+
}
|
|
302
|
+
if (setNext) {
|
|
303
|
+
return "update downloaded, will install next background";
|
|
304
|
+
}
|
|
305
|
+
return "update downloaded, autoUpdate onlyDownload";
|
|
306
|
+
}
|
|
307
|
+
|
|
262
308
|
public String randomString() {
|
|
263
309
|
final StringBuilder sb = new StringBuilder(10);
|
|
264
310
|
for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
|
|
@@ -287,6 +333,31 @@ public class CapgoUpdater {
|
|
|
287
333
|
this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
|
|
288
334
|
}
|
|
289
335
|
|
|
336
|
+
private void requireSessionKeyForEncryptedUpdate(final String sessionKey) throws IOException {
|
|
337
|
+
if (!this.publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
|
|
338
|
+
logger.error("Public key present but no valid session key provided");
|
|
339
|
+
this.sendStats("session_key_required");
|
|
340
|
+
throw new IOException("Session key required when public key is present");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private void requireBundleChecksum(final String checksum) throws IOException {
|
|
345
|
+
if (checksum == null || checksum.isEmpty()) {
|
|
346
|
+
logger.error("No checksum provided");
|
|
347
|
+
this.sendStats("checksum_required");
|
|
348
|
+
throw new IOException("Checksum required");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
static boolean containsPathTraversalSegment(final String relativePath) {
|
|
353
|
+
for (final String segment : relativePath.split("/")) {
|
|
354
|
+
if ("..".equals(segment)) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
290
361
|
static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
|
|
291
362
|
if (relativePath == null || relativePath.isEmpty()) {
|
|
292
363
|
throw new IOException("Invalid empty path");
|
|
@@ -294,6 +365,9 @@ public class CapgoUpdater {
|
|
|
294
365
|
if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
|
|
295
366
|
throw new IOException("Invalid path separator");
|
|
296
367
|
}
|
|
368
|
+
if (containsPathTraversalSegment(relativePath)) {
|
|
369
|
+
throw new IOException("Path traversal segments are not allowed");
|
|
370
|
+
}
|
|
297
371
|
if (new File(relativePath).isAbsolute()) {
|
|
298
372
|
throw new IOException("Absolute paths are not allowed");
|
|
299
373
|
}
|
|
@@ -304,13 +378,18 @@ public class CapgoUpdater {
|
|
|
304
378
|
final String targetPath = canonicalTarget.getPath();
|
|
305
379
|
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
306
380
|
|
|
307
|
-
|
|
381
|
+
// Require a strict child of the base. Equality would accept "." and wipe/write the root.
|
|
382
|
+
if (!targetPath.startsWith(normalizedBasePath)) {
|
|
308
383
|
throw new IOException("Path escapes base directory: " + relativePath);
|
|
309
384
|
}
|
|
310
385
|
|
|
311
386
|
return canonicalTarget;
|
|
312
387
|
}
|
|
313
388
|
|
|
389
|
+
static File resolveBundleDirectory(final File documentsDir, final String bundleId) throws IOException {
|
|
390
|
+
return resolvePathInsideDirectory(new File(documentsDir, bundleDirectory), bundleId);
|
|
391
|
+
}
|
|
392
|
+
|
|
314
393
|
public String getKeyId() {
|
|
315
394
|
return this.cachedKeyId;
|
|
316
395
|
}
|
|
@@ -378,11 +457,11 @@ public class CapgoUpdater {
|
|
|
378
457
|
}
|
|
379
458
|
}
|
|
380
459
|
|
|
381
|
-
private void flattenAssets(final File sourceFile, final
|
|
460
|
+
private void flattenAssets(final File sourceFile, final File destinationFile) throws IOException {
|
|
382
461
|
if (!sourceFile.exists()) {
|
|
383
462
|
throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
|
|
384
463
|
}
|
|
385
|
-
|
|
464
|
+
assertPathInsideBundleRoot(destinationFile);
|
|
386
465
|
Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
|
|
387
466
|
final String[] entries = sourceFile.list(this.filter);
|
|
388
467
|
if (entries == null || entries.length == 0) {
|
|
@@ -411,7 +490,13 @@ public class CapgoUpdater {
|
|
|
411
490
|
return;
|
|
412
491
|
}
|
|
413
492
|
|
|
414
|
-
final File bundleDir
|
|
493
|
+
final File bundleDir;
|
|
494
|
+
try {
|
|
495
|
+
bundleDir = this.getBundleDirectory(id);
|
|
496
|
+
} catch (IOException e) {
|
|
497
|
+
logger.debug("Skip delta cache population: invalid bundle id");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
415
500
|
if (!bundleDir.exists()) {
|
|
416
501
|
logger.debug("Skip delta cache population: bundle dir missing");
|
|
417
502
|
return;
|
|
@@ -499,7 +584,10 @@ public class CapgoUpdater {
|
|
|
499
584
|
if (fileHash.isEmpty()) {
|
|
500
585
|
return "";
|
|
501
586
|
}
|
|
502
|
-
if (this.publicKey != null && !this.publicKey.isEmpty()
|
|
587
|
+
if (this.publicKey != null && !this.publicKey.isEmpty()) {
|
|
588
|
+
if (!CryptoCipher.isValidSessionKey(sessionKey)) {
|
|
589
|
+
return "";
|
|
590
|
+
}
|
|
503
591
|
try {
|
|
504
592
|
fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
|
|
505
593
|
} catch (Exception e) {
|
|
@@ -663,12 +751,36 @@ public class CapgoUpdater {
|
|
|
663
751
|
}
|
|
664
752
|
}
|
|
665
753
|
|
|
666
|
-
private void
|
|
754
|
+
private void notifyLaunchDownloadReady(
|
|
755
|
+
final boolean awaitedByCaller,
|
|
756
|
+
final boolean success,
|
|
757
|
+
final boolean directInstall,
|
|
758
|
+
final boolean previewSession,
|
|
759
|
+
final boolean setNext,
|
|
760
|
+
final BundleInfo bundle,
|
|
761
|
+
final long settlementToken
|
|
762
|
+
) {
|
|
763
|
+
if (!shouldNotifyLaunchDownloadReady(awaitedByCaller, success, directInstall, previewSession)) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
final BundleInfo readyBundle = bundle != null ? bundle : this.getCurrentBundle();
|
|
767
|
+
this.backgroundDownloadSettled(readyBundle, launchDownloadReadyStatus(success, setNext), settlementToken);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private void observeWorkProgress(
|
|
771
|
+
Context context,
|
|
772
|
+
String id,
|
|
773
|
+
String observedVersion,
|
|
774
|
+
boolean setNext,
|
|
775
|
+
final long settlementToken
|
|
776
|
+
) {
|
|
667
777
|
if (!(context instanceof LifecycleOwner)) {
|
|
668
778
|
logger.error("Context is not a LifecycleOwner, cannot observe work progress");
|
|
669
779
|
return;
|
|
670
780
|
}
|
|
671
781
|
|
|
782
|
+
final AtomicBoolean terminalHandled = new AtomicBoolean(false);
|
|
783
|
+
final AtomicBoolean launchReleasedWhileRetrying = new AtomicBoolean(false);
|
|
672
784
|
activity.runOnUiThread(() -> {
|
|
673
785
|
WorkManager.getInstance(context)
|
|
674
786
|
.getWorkInfosByTagLiveData(id)
|
|
@@ -679,11 +791,40 @@ public class CapgoUpdater {
|
|
|
679
791
|
Data progress = workInfo.getProgress();
|
|
680
792
|
|
|
681
793
|
switch (workInfo.getState()) {
|
|
794
|
+
case ENQUEUED:
|
|
795
|
+
// A run attempt already happened, so WorkManager scheduled a retry (network lost midway,
|
|
796
|
+
// 5xx, ...). Retries back off without limit, so a direct install must not keep the launch
|
|
797
|
+
// (and its splashscreen) blocked until the network comes back.
|
|
798
|
+
if (
|
|
799
|
+
workInfo.getRunAttemptCount() > 0 &&
|
|
800
|
+
shouldReleaseLaunchWhileRetrying(
|
|
801
|
+
downloadFutures.containsKey(id),
|
|
802
|
+
setNext,
|
|
803
|
+
Boolean.TRUE.equals(CapgoUpdater.this.directUpdate),
|
|
804
|
+
CapgoUpdater.this.previewSession
|
|
805
|
+
) &&
|
|
806
|
+
launchReleasedWhileRetrying.compareAndSet(false, true)
|
|
807
|
+
) {
|
|
808
|
+
logger.warn("Direct update download is retrying, continuing launch on the current bundle");
|
|
809
|
+
// Same fallback as the autoSplashscreen timeout: a later success installs on next background.
|
|
810
|
+
CapgoUpdater.this.directUpdate = false;
|
|
811
|
+
io.execute(
|
|
812
|
+
() ->
|
|
813
|
+
backgroundDownloadSettled(
|
|
814
|
+
getCurrentBundle(),
|
|
815
|
+
launchDownloadReadyStatus(false, false),
|
|
816
|
+
settlementToken
|
|
817
|
+
)
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
break;
|
|
682
821
|
case RUNNING:
|
|
683
822
|
int percent = progress.getInt(DownloadService.PERCENT, 0);
|
|
684
823
|
notifyDownload(id, percent);
|
|
685
824
|
break;
|
|
686
825
|
case SUCCEEDED:
|
|
826
|
+
if (!terminalHandled.compareAndSet(false, true)) break;
|
|
827
|
+
observedDownloadVersions.remove(observedVersion);
|
|
687
828
|
logger.info("Download succeeded: " + workInfo.getState());
|
|
688
829
|
Data outputData = workInfo.getOutputData();
|
|
689
830
|
String dest = outputData.getString(DownloadService.FILEDEST);
|
|
@@ -693,6 +834,10 @@ public class CapgoUpdater {
|
|
|
693
834
|
boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
|
|
694
835
|
|
|
695
836
|
io.execute(() -> {
|
|
837
|
+
// finishDownload clears directUpdate, so read the install plan first.
|
|
838
|
+
final boolean directInstall =
|
|
839
|
+
setNext && Boolean.TRUE.equals(CapgoUpdater.this.directUpdate) && !CapgoUpdater.this.previewSession;
|
|
840
|
+
final boolean previewSession = CapgoUpdater.this.previewSession;
|
|
696
841
|
boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
|
|
697
842
|
BundleInfo resultBundle;
|
|
698
843
|
if (!success) {
|
|
@@ -719,14 +864,29 @@ public class CapgoUpdater {
|
|
|
719
864
|
resultBundle = getBundleInfo(id);
|
|
720
865
|
}
|
|
721
866
|
|
|
722
|
-
// Complete the future if it exists
|
|
867
|
+
// Complete the future if it exists. download() waits on it.
|
|
868
|
+
// downloadBackground does not, so the launch check must emit appReady here.
|
|
723
869
|
CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
|
|
724
870
|
if (future != null) {
|
|
725
871
|
future.complete(resultBundle);
|
|
726
872
|
}
|
|
873
|
+
final BundleInfo readyBundle = success && setNext ? resultBundle : null;
|
|
874
|
+
if (!launchReleasedWhileRetrying.get()) {
|
|
875
|
+
notifyLaunchDownloadReady(
|
|
876
|
+
future != null,
|
|
877
|
+
success,
|
|
878
|
+
directInstall,
|
|
879
|
+
previewSession,
|
|
880
|
+
setNext,
|
|
881
|
+
readyBundle,
|
|
882
|
+
settlementToken
|
|
883
|
+
);
|
|
884
|
+
}
|
|
727
885
|
});
|
|
728
886
|
break;
|
|
729
887
|
case FAILED:
|
|
888
|
+
if (!terminalHandled.compareAndSet(false, true)) break;
|
|
889
|
+
observedDownloadVersions.remove(observedVersion);
|
|
730
890
|
Data failedData = workInfo.getOutputData();
|
|
731
891
|
String error = failedData.getString(DownloadService.ERROR);
|
|
732
892
|
logger.error("Download failed");
|
|
@@ -761,9 +921,22 @@ public class CapgoUpdater {
|
|
|
761
921
|
if (failedFuture != null) {
|
|
762
922
|
failedFuture.complete(failedBundle);
|
|
763
923
|
}
|
|
924
|
+
if (!launchReleasedWhileRetrying.get()) {
|
|
925
|
+
notifyLaunchDownloadReady(
|
|
926
|
+
failedFuture != null,
|
|
927
|
+
false,
|
|
928
|
+
false,
|
|
929
|
+
false,
|
|
930
|
+
false,
|
|
931
|
+
null,
|
|
932
|
+
settlementToken
|
|
933
|
+
);
|
|
934
|
+
}
|
|
764
935
|
});
|
|
765
936
|
break;
|
|
766
937
|
case CANCELLED:
|
|
938
|
+
if (!terminalHandled.compareAndSet(false, true)) break;
|
|
939
|
+
observedDownloadVersions.remove(observedVersion);
|
|
767
940
|
DataManager.getInstance().clearManifest(id);
|
|
768
941
|
CompletableFuture<BundleInfo> cancelledFuture = downloadFutures.remove(id);
|
|
769
942
|
if (cancelledFuture != null) {
|
|
@@ -783,13 +956,15 @@ public class CapgoUpdater {
|
|
|
783
956
|
final String sessionKey,
|
|
784
957
|
final String checksum,
|
|
785
958
|
final JSONArray manifest,
|
|
786
|
-
final boolean setNext
|
|
959
|
+
final boolean setNext,
|
|
960
|
+
final long settlementToken
|
|
787
961
|
) {
|
|
788
962
|
if (this.activity == null) {
|
|
789
963
|
logger.error("Activity is null, cannot observe work progress");
|
|
790
964
|
return;
|
|
791
965
|
}
|
|
792
|
-
|
|
966
|
+
observedDownloadVersions.add(version);
|
|
967
|
+
observeWorkProgress(this.activity, id, version, setNext, settlementToken);
|
|
793
968
|
|
|
794
969
|
if (manifest != null) {
|
|
795
970
|
DataManager.getInstance().setManifest(id, manifest);
|
|
@@ -835,31 +1010,24 @@ public class CapgoUpdater {
|
|
|
835
1010
|
String checksum = "";
|
|
836
1011
|
|
|
837
1012
|
try {
|
|
1013
|
+
this.requireSessionKeyForEncryptedUpdate(sessionKey);
|
|
838
1014
|
this.notifyDownload(id, 71);
|
|
839
1015
|
downloaded = new File(this.documentsDir, dest);
|
|
840
1016
|
|
|
841
1017
|
if (!isManifest) {
|
|
842
|
-
String
|
|
1018
|
+
String expectedChecksum = Objects.requireNonNullElse(checksumRes, "");
|
|
1019
|
+
this.requireBundleChecksum(expectedChecksum);
|
|
843
1020
|
|
|
844
|
-
|
|
845
|
-
if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
|
|
846
|
-
logger.error("Public key present but no checksum provided");
|
|
847
|
-
this.sendStats("checksum_required");
|
|
848
|
-
throw new IOException("Checksum required when public key is present: " + id);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (!sessionKey.isEmpty()) {
|
|
1021
|
+
if (CryptoCipher.isValidSessionKey(sessionKey)) {
|
|
852
1022
|
CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
|
|
853
|
-
|
|
854
|
-
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
855
|
-
} else {
|
|
856
|
-
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
1023
|
+
expectedChecksum = CryptoCipher.decryptChecksum(checksumRes, publicKey);
|
|
857
1024
|
}
|
|
1025
|
+
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
858
1026
|
CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
|
|
859
|
-
CryptoCipher.logChecksumInfo("Expected checksum",
|
|
860
|
-
if (
|
|
1027
|
+
CryptoCipher.logChecksumInfo("Expected checksum", expectedChecksum);
|
|
1028
|
+
if (!expectedChecksum.equals(checksum)) {
|
|
861
1029
|
logger.error("Checksum mismatch");
|
|
862
|
-
logger.debug("Expected: " +
|
|
1030
|
+
logger.debug("Expected: " + expectedChecksum + ", Got: " + checksum);
|
|
863
1031
|
this.sendStats("checksum_fail");
|
|
864
1032
|
throw new IOException("Checksum failed: " + id);
|
|
865
1033
|
}
|
|
@@ -887,13 +1055,11 @@ public class CapgoUpdater {
|
|
|
887
1055
|
if (!isManifest) {
|
|
888
1056
|
extractedDir = this.unzip(id, downloaded, TEMP_UNZIP_PREFIX + this.randomString());
|
|
889
1057
|
this.notifyDownload(id, 91);
|
|
890
|
-
|
|
891
|
-
this.flattenAssets(extractedDir, idName);
|
|
1058
|
+
this.flattenAssets(extractedDir, this.getBundleDirectory(id));
|
|
892
1059
|
this.cacheBundleFilesAsync(id);
|
|
893
1060
|
} else {
|
|
894
1061
|
this.notifyDownload(id, 91);
|
|
895
|
-
|
|
896
|
-
this.flattenAssets(downloaded, idName);
|
|
1062
|
+
this.flattenAssets(downloaded, this.getBundleDirectory(id));
|
|
897
1063
|
downloaded.delete();
|
|
898
1064
|
}
|
|
899
1065
|
// Remove old bundle info and set new one
|
|
@@ -1112,18 +1278,44 @@ public class CapgoUpdater {
|
|
|
1112
1278
|
}
|
|
1113
1279
|
}
|
|
1114
1280
|
|
|
1281
|
+
private void assertPathInsideDocumentsDir(final File target) throws IOException {
|
|
1282
|
+
if (this.documentsDir == null) {
|
|
1283
|
+
throw new IOException("Documents directory unavailable");
|
|
1284
|
+
}
|
|
1285
|
+
final File canonicalBase = this.documentsDir.getCanonicalFile();
|
|
1286
|
+
final File canonicalTarget = target.getCanonicalFile();
|
|
1287
|
+
final String basePath = canonicalBase.getPath();
|
|
1288
|
+
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
1289
|
+
final String targetPath = canonicalTarget.getPath();
|
|
1290
|
+
if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
|
|
1291
|
+
throw new IOException("Path escapes updater storage: " + target.getPath());
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private void assertPathInsideBundleRoot(final File target) throws IOException {
|
|
1296
|
+
final File bundleRoot = new File(this.documentsDir, bundleDirectory).getCanonicalFile();
|
|
1297
|
+
final File canonicalTarget = target.getCanonicalFile();
|
|
1298
|
+
final String basePath = bundleRoot.getPath();
|
|
1299
|
+
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
1300
|
+
final String targetPath = canonicalTarget.getPath();
|
|
1301
|
+
if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
|
|
1302
|
+
throw new IOException("Path escapes bundle storage: " + target.getPath());
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1115
1306
|
private void safeDelete(final File target) {
|
|
1116
1307
|
if (target == null || !target.exists()) {
|
|
1117
1308
|
return;
|
|
1118
1309
|
}
|
|
1119
1310
|
try {
|
|
1311
|
+
this.assertPathInsideDocumentsDir(target);
|
|
1120
1312
|
if (target.isDirectory()) {
|
|
1121
1313
|
this.deleteDirectory(target);
|
|
1122
1314
|
} else if (!target.delete()) {
|
|
1123
1315
|
logger.warn("Failed to delete file: " + target.getAbsolutePath());
|
|
1124
1316
|
}
|
|
1125
1317
|
} catch (IOException cleanupError) {
|
|
1126
|
-
logger.warn("
|
|
1318
|
+
logger.warn("Refusing unsafe delete for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
|
|
1127
1319
|
}
|
|
1128
1320
|
}
|
|
1129
1321
|
|
|
@@ -1379,24 +1571,25 @@ public class CapgoUpdater {
|
|
|
1379
1571
|
}
|
|
1380
1572
|
}
|
|
1381
1573
|
|
|
1382
|
-
public void downloadBackground(
|
|
1383
|
-
final String url,
|
|
1384
|
-
final String version,
|
|
1385
|
-
final String sessionKey,
|
|
1386
|
-
final String checksum,
|
|
1387
|
-
final JSONArray manifest
|
|
1388
|
-
) {
|
|
1389
|
-
downloadBackground(url, version, sessionKey, checksum, manifest, true);
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
1574
|
public void downloadBackground(
|
|
1393
1575
|
final String url,
|
|
1394
1576
|
final String version,
|
|
1395
1577
|
final String sessionKey,
|
|
1396
1578
|
final String checksum,
|
|
1397
1579
|
final JSONArray manifest,
|
|
1398
|
-
final boolean setNext
|
|
1580
|
+
final boolean setNext,
|
|
1581
|
+
final long settlementToken
|
|
1399
1582
|
) {
|
|
1583
|
+
try {
|
|
1584
|
+
this.requireSessionKeyForEncryptedUpdate(sessionKey);
|
|
1585
|
+
if (manifest == null) {
|
|
1586
|
+
this.requireBundleChecksum(checksum);
|
|
1587
|
+
}
|
|
1588
|
+
} catch (final IOException e) {
|
|
1589
|
+
logger.error("Download blocked: " + e.getMessage());
|
|
1590
|
+
this.settleBackgroundDownload(null, launchDownloadReadyStatus(false, setNext), settlementToken);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1400
1593
|
if (!this.runDownloadGateQuiet()) {
|
|
1401
1594
|
return;
|
|
1402
1595
|
}
|
|
@@ -1410,9 +1603,26 @@ public class CapgoUpdater {
|
|
|
1410
1603
|
// Cancel the failed download and allow retry
|
|
1411
1604
|
if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
|
|
1412
1605
|
logger.error("Failed to cancel previous download before retry");
|
|
1606
|
+
this.settleBackgroundDownload(
|
|
1607
|
+
this.getCurrentBundle(),
|
|
1608
|
+
launchDownloadReadyStatus(false, setNext),
|
|
1609
|
+
settlementToken
|
|
1610
|
+
);
|
|
1413
1611
|
return;
|
|
1414
1612
|
}
|
|
1415
1613
|
logger.info("Retrying failed download for version: " + version);
|
|
1614
|
+
} else if (shouldRestartOrphanedDownload(observedDownloadVersions.contains(version))) {
|
|
1615
|
+
// Left over from a killed process: nothing would finish it or release the launch, so start over.
|
|
1616
|
+
if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
|
|
1617
|
+
logger.error("Failed to cancel orphaned download before restarting it");
|
|
1618
|
+
this.settleBackgroundDownload(
|
|
1619
|
+
this.getCurrentBundle(),
|
|
1620
|
+
launchDownloadReadyStatus(false, setNext),
|
|
1621
|
+
settlementToken
|
|
1622
|
+
);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
logger.info("Restarting download orphaned by a previous process for version: " + version);
|
|
1416
1626
|
} else {
|
|
1417
1627
|
logger.info("Version already downloading: " + version);
|
|
1418
1628
|
return;
|
|
@@ -1423,10 +1633,12 @@ public class CapgoUpdater {
|
|
|
1423
1633
|
this.notifyDownload(id, 0);
|
|
1424
1634
|
this.notifyDownload(id, 5);
|
|
1425
1635
|
|
|
1426
|
-
this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext);
|
|
1636
|
+
this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext, settlementToken);
|
|
1427
1637
|
}
|
|
1428
1638
|
|
|
1429
1639
|
public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
|
|
1640
|
+
this.requireSessionKeyForEncryptedUpdate(sessionKey);
|
|
1641
|
+
this.requireBundleChecksum(checksum);
|
|
1430
1642
|
this.runDownloadGate();
|
|
1431
1643
|
// Check for existing bundle with same version and clean up if in error state
|
|
1432
1644
|
BundleInfo existingBundle = this.getBundleInfoByName(version);
|
|
@@ -1448,7 +1660,7 @@ public class CapgoUpdater {
|
|
|
1448
1660
|
downloadFutures.put(id, downloadFuture);
|
|
1449
1661
|
|
|
1450
1662
|
// Start the download
|
|
1451
|
-
this.download(id, url, dest, version, sessionKey, checksum, null, false);
|
|
1663
|
+
this.download(id, url, dest, version, sessionKey, checksum, null, false, 0L);
|
|
1452
1664
|
|
|
1453
1665
|
// Wait for completion without timeout
|
|
1454
1666
|
try {
|
|
@@ -1478,6 +1690,7 @@ public class CapgoUpdater {
|
|
|
1478
1690
|
final String checksum,
|
|
1479
1691
|
final JSONArray manifest
|
|
1480
1692
|
) throws IOException {
|
|
1693
|
+
this.requireSessionKeyForEncryptedUpdate(sessionKey);
|
|
1481
1694
|
this.runDownloadGate();
|
|
1482
1695
|
if (manifest == null) {
|
|
1483
1696
|
return download(url, version, sessionKey, checksum);
|
|
@@ -1503,7 +1716,7 @@ public class CapgoUpdater {
|
|
|
1503
1716
|
downloadFutures.put(id, downloadFuture);
|
|
1504
1717
|
|
|
1505
1718
|
// Start the download
|
|
1506
|
-
this.download(id, url, dest, version, sessionKey, checksum, manifest, false);
|
|
1719
|
+
this.download(id, url, dest, version, sessionKey, checksum, manifest, false, 0L);
|
|
1507
1720
|
|
|
1508
1721
|
// Wait for completion without timeout
|
|
1509
1722
|
try {
|
|
@@ -1559,6 +1772,14 @@ public class CapgoUpdater {
|
|
|
1559
1772
|
|
|
1560
1773
|
public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
|
|
1561
1774
|
synchronized (this.deleteLock) {
|
|
1775
|
+
final File bundle;
|
|
1776
|
+
try {
|
|
1777
|
+
bundle = this.getBundleDirectory(id);
|
|
1778
|
+
} catch (IOException e) {
|
|
1779
|
+
logger.error("Cannot delete bundle with invalid id");
|
|
1780
|
+
logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1562
1783
|
final BundleInfo deleted = this.getBundleInfo(id);
|
|
1563
1784
|
if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
|
|
1564
1785
|
logger.error("Cannot delete current or builtin bundle");
|
|
@@ -1584,7 +1805,6 @@ public class CapgoUpdater {
|
|
|
1584
1805
|
return false;
|
|
1585
1806
|
}
|
|
1586
1807
|
|
|
1587
|
-
final File bundle = this.getBundleDirectory(id);
|
|
1588
1808
|
final boolean hadRegistry = this.hasStoredBundleInfo(id);
|
|
1589
1809
|
final boolean hadFolder = bundle.exists();
|
|
1590
1810
|
if (!hadRegistry && !hadFolder) {
|
|
@@ -1732,12 +1952,17 @@ public class CapgoUpdater {
|
|
|
1732
1952
|
this.editor.commit();
|
|
1733
1953
|
}
|
|
1734
1954
|
|
|
1735
|
-
private File getBundleDirectory(final String id) {
|
|
1736
|
-
return
|
|
1955
|
+
private File getBundleDirectory(final String id) throws IOException {
|
|
1956
|
+
return resolveBundleDirectory(this.documentsDir, id);
|
|
1737
1957
|
}
|
|
1738
1958
|
|
|
1739
1959
|
private boolean bundleExists(final String id) {
|
|
1740
|
-
final File bundle
|
|
1960
|
+
final File bundle;
|
|
1961
|
+
try {
|
|
1962
|
+
bundle = this.getBundleDirectory(id);
|
|
1963
|
+
} catch (IOException e) {
|
|
1964
|
+
return false;
|
|
1965
|
+
}
|
|
1741
1966
|
final BundleInfo bundleInfo = this.getBundleInfo(id);
|
|
1742
1967
|
return (
|
|
1743
1968
|
bundle.isDirectory() &&
|
|
@@ -1814,7 +2039,16 @@ public class CapgoUpdater {
|
|
|
1814
2039
|
this.reset();
|
|
1815
2040
|
return true;
|
|
1816
2041
|
}
|
|
1817
|
-
final File bundle
|
|
2042
|
+
final File bundle;
|
|
2043
|
+
try {
|
|
2044
|
+
bundle = this.getBundleDirectory(id);
|
|
2045
|
+
} catch (IOException e) {
|
|
2046
|
+
logger.error("Invalid bundle id");
|
|
2047
|
+
logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
|
|
2048
|
+
this.setBundleStatus(id, BundleStatus.ERROR);
|
|
2049
|
+
this.sendStats("set_fail", newBundle.getVersionName());
|
|
2050
|
+
return false;
|
|
2051
|
+
}
|
|
1818
2052
|
logger.info("Setting next active bundle: " + id);
|
|
1819
2053
|
if (this.bundleExists(id)) {
|
|
1820
2054
|
var currentBundleName = this.getCurrentBundle().getVersionName();
|
|
@@ -1832,8 +2066,12 @@ public class CapgoUpdater {
|
|
|
1832
2066
|
if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
|
|
1833
2067
|
return false;
|
|
1834
2068
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
2069
|
+
try {
|
|
2070
|
+
this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
|
|
2071
|
+
return true;
|
|
2072
|
+
} catch (IOException e) {
|
|
2073
|
+
return false;
|
|
2074
|
+
}
|
|
1837
2075
|
}
|
|
1838
2076
|
|
|
1839
2077
|
boolean stagePreviewFallbackReload(final BundleInfo bundle) {
|
|
@@ -1847,8 +2085,12 @@ public class CapgoUpdater {
|
|
|
1847
2085
|
if (!this.bundleExists(bundle.getId())) {
|
|
1848
2086
|
return false;
|
|
1849
2087
|
}
|
|
1850
|
-
|
|
1851
|
-
|
|
2088
|
+
try {
|
|
2089
|
+
this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
|
|
2090
|
+
return true;
|
|
2091
|
+
} catch (IOException e) {
|
|
2092
|
+
return false;
|
|
2093
|
+
}
|
|
1852
2094
|
}
|
|
1853
2095
|
|
|
1854
2096
|
void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
|
|
@@ -2409,8 +2651,18 @@ public class CapgoUpdater {
|
|
|
2409
2651
|
final SharedPreferences.Editor editor,
|
|
2410
2652
|
final String defaultChannelKey,
|
|
2411
2653
|
final String configDefaultChannel,
|
|
2654
|
+
final boolean allowSetDefaultChannel,
|
|
2412
2655
|
final Callback callback
|
|
2413
2656
|
) {
|
|
2657
|
+
if (!allowSetDefaultChannel) {
|
|
2658
|
+
logger.error("unsetChannel is disabled by allowSetDefaultChannel config");
|
|
2659
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2660
|
+
retError.put("message", "unsetChannel is disabled by configuration");
|
|
2661
|
+
retError.put("error", "disabled_by_config");
|
|
2662
|
+
callback.callback(retError);
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2414
2666
|
// Clear persisted defaultChannel and revert to config value
|
|
2415
2667
|
editor.remove(defaultChannelKey);
|
|
2416
2668
|
editor.apply();
|
|
@@ -3361,6 +3613,12 @@ public class CapgoUpdater {
|
|
|
3361
3613
|
if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
|
|
3362
3614
|
return false;
|
|
3363
3615
|
}
|
|
3616
|
+
if (next.equals(this.getCurrentBundleId()) && BundleStatus.SUCCESS == newBundle.getStatus()) {
|
|
3617
|
+
logger.info("Bundle " + next + " is already the current successful bundle. Skip next().");
|
|
3618
|
+
this.editor.remove(NEXT_VERSION);
|
|
3619
|
+
this.editor.commit();
|
|
3620
|
+
return true;
|
|
3621
|
+
}
|
|
3364
3622
|
this.editor.putString(NEXT_VERSION, next);
|
|
3365
3623
|
this.setBundleStatus(next, BundleStatus.PENDING);
|
|
3366
3624
|
bundleToNotify = newBundle;
|