@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 +8 -1
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +25 -39
- package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
- package/android/src/main/java/com/otakit/updater/ZipUtils.java +12 -1
- package/ios/Sources/UpdaterPlugin/ZipUtils.swift +83 -43
- package/ios/Tests/UpdaterPluginTests/ZipUtilsTests.swift +227 -0
- package/package.json +1 -1
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
409
|
+
if (applyStaged()) {
|
|
410
410
|
return;
|
|
411
411
|
}
|
|
412
412
|
DownloadResolution result = downloadLatest(true, null);
|
|
413
413
|
if (result.isForcedStaged()) {
|
|
414
|
-
requireApplyStaged(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1009
|
-
if (!applyStaged(
|
|
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"
|
|
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
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
bridge
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|