expo-updates 29.0.6 → 29.0.7

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/CHANGELOG.md CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 29.0.7 — 2025-08-31
14
+
15
+ ### 💡 Others
16
+
17
+ - [iOS] Migrate multipart parsers to Swift. ([#39144](https://github.com/expo/expo/pull/39144) by [@alanjhughes](https://github.com/alanjhughes))
18
+
13
19
  ## 29.0.6 — 2025-08-28
14
20
 
15
21
  _This version does not introduce any user-facing changes._
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '29.0.6'
45
+ version = '29.0.7'
46
46
 
47
47
  // Utility method to derive boolean values from the environment or from Java properties,
48
48
  // and return them as strings to be used in BuildConfig fields
@@ -88,7 +88,7 @@ android {
88
88
  namespace "expo.modules.updates"
89
89
  defaultConfig {
90
90
  versionCode 31
91
- versionName '29.0.6'
91
+ versionName '29.0.7'
92
92
  consumerProguardFiles("proguard-rules.pro")
93
93
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
94
94
 
@@ -373,10 +373,11 @@ public final class FileDownloader {
373
373
  let contentType = httpResponse.value(forHTTPHeaderField: "content-type") ?? ""
374
374
 
375
375
  if contentType.lowercased().hasPrefix("multipart/") {
376
- guard let contentTypeParameters = EXUpdatesParameterParser().parseParameterString(
377
- contentType,
378
- withDelimiter: FileDownloader.ParameterParserSemicolonDelimiter
379
- ) as? [String: Any],
376
+ guard let scalar = UnicodeScalar(FileDownloader.ParameterParserSemicolonDelimiter) else {
377
+ return
378
+ }
379
+ let contentTypeParameters = UpdatesParameterParser().parseParameterString(contentType, withDelimiter: Character(scalar))
380
+ guard
380
381
  let boundaryParameterValue: String = contentTypeParameters.optionalValue(forKey: "boundary") else {
381
382
  let cause = UpdatesError.fileDownloaderMissingMultipartBoundary
382
383
  logger.error(cause: cause, code: UpdatesErrorCode.unknown)
@@ -425,7 +426,7 @@ public final class FileDownloader {
425
426
  successBlock: @escaping RemoteUpdateDownloadSuccessBlock,
426
427
  errorBlock: @escaping RemoteUpdateDownloadErrorBlock
427
428
  ) {
428
- let reader = EXUpdatesMultipartStreamReader(inputStream: InputStream(data: data), boundary: boundary)
429
+ let reader = UpdatesMultipartStreamReader(inputStream: InputStream(data: data), boundary: boundary)
429
430
 
430
431
  var manifestPartHeadersAndData: ([String: Any], Data)?
431
432
  var extensionsData: Data?
@@ -433,19 +434,23 @@ public final class FileDownloader {
433
434
  var directivePartHeadersAndData: ([String: Any], Data)?
434
435
 
435
436
  let completed = data.isEmpty || reader.readAllParts { headers, content, _ in
436
- if let contentDisposition = (headers as! [String: Any]).stringValueForCaseInsensitiveKey("content-disposition") {
437
- if let contentDispositionParameters = EXUpdatesParameterParser().parseParameterString(
438
- contentDisposition,
439
- withDelimiter: FileDownloader.ParameterParserSemicolonDelimiter
440
- ) as? [String: Any],
437
+ guard let headers else {
438
+ return
439
+ }
440
+ if let contentDisposition = headers.stringValueForCaseInsensitiveKey("content-disposition") {
441
+ guard let scalar = UnicodeScalar(FileDownloader.ParameterParserSemicolonDelimiter) else {
442
+ return
443
+ }
444
+ let contentDispositionParameters = UpdatesParameterParser().parseParameterString(contentDisposition, withDelimiter: Character(scalar))
445
+ if
441
446
  let contentDispositionNameFieldValue: String = contentDispositionParameters.optionalValue(forKey: "name") {
442
447
  switch contentDispositionNameFieldValue {
443
448
  case FileDownloader.MultipartManifestPartName:
444
- if let headers = headers as? [String: Any], let content = content {
449
+ if let content {
445
450
  manifestPartHeadersAndData = (headers, content)
446
451
  }
447
452
  case FileDownloader.MultipartDirectivePartName:
448
- if let headers = headers as? [String: Any], let content = content {
453
+ if let content {
449
454
  directivePartHeadersAndData = (headers, content)
450
455
  }
451
456
  case FileDownloader.MultipartExtensionsPartName:
@@ -0,0 +1,169 @@
1
+ // Copyright © 2019 650 Industries. All rights reserved.
2
+
3
+ import Foundation
4
+
5
+ public typealias MultipartCallback = (_ headers: [String: Any]?, _ content: Data?, _ done: Bool) -> Void
6
+
7
+ /**
8
+ * Fork of React Native's RCTMultipartStreamReader that doesn't necessarily
9
+ * expect a preamble (first boundary is not necessarily preceded by CRLF).
10
+ */
11
+ public final class UpdatesMultipartStreamReader {
12
+ private enum Constants {
13
+ static let crlf = "\r\n"
14
+ static let bufferLength = 4 * 1024
15
+ }
16
+
17
+ private let stream: InputStream
18
+ private let boundary: String
19
+
20
+ public init(inputStream: InputStream, boundary: String) {
21
+ self.stream = inputStream
22
+ self.boundary = boundary
23
+ }
24
+
25
+ public func readAllParts(withCompletionCallback callback: @escaping MultipartCallback) -> Bool {
26
+ var chunkStart = 0
27
+ var bytesSeen = 0
28
+
29
+ // First delimiter doesn't necessarily need to be preceded by CRLF (boundary can be first thing in body)
30
+ let firstDelimiter = Data("--\(boundary)\(Constants.crlf)".utf8)
31
+ let restDelimiter = Data("\(Constants.crlf)--\(boundary)\(Constants.crlf)".utf8)
32
+ let closeDelimiter = Data("\(Constants.crlf)--\(boundary)--\(Constants.crlf)".utf8)
33
+
34
+ var delimiter = firstDelimiter
35
+ var content = Data()
36
+ var currentHeaders: [String: Any]?
37
+
38
+ var buffer = [UInt8](repeating: 0, count: Constants.bufferLength)
39
+
40
+ stream.open()
41
+ defer { stream.close() }
42
+
43
+ while true {
44
+ var isCloseDelimiter = false
45
+
46
+ // Search only a subset of chunk that we haven't seen before + few bytes
47
+ // to allow for the edge case when the delimiter is cut by read call
48
+ let searchStart = max(bytesSeen - closeDelimiter.count, chunkStart)
49
+ let remainingBufferRange = NSRange(location: searchStart, length: content.count - searchStart)
50
+
51
+ // Check for delimiters
52
+ var range = content.range(of: delimiter, options: [], in: remainingBufferRange)
53
+ if range.location == NSNotFound {
54
+ isCloseDelimiter = true
55
+ range = content.range(of: closeDelimiter, options: [], in: remainingBufferRange)
56
+ }
57
+
58
+ if range.location == NSNotFound {
59
+ if currentHeaders == nil {
60
+ if let newHeaders = parseHeadersIfFound(in: content, range: remainingBufferRange, chunkStart: chunkStart) {
61
+ currentHeaders = newHeaders
62
+ }
63
+ }
64
+
65
+ bytesSeen = content.count
66
+ let bytesRead = buffer.withUnsafeMutableBytes { bytes in
67
+ guard let baseAddress = bytes.bindMemory(to: UInt8.self).baseAddress else {
68
+ return 0
69
+ }
70
+ return stream.read(baseAddress, maxLength: Constants.bufferLength)
71
+ }
72
+ guard bytesRead > 0, stream.streamError == nil else {
73
+ return false
74
+ }
75
+ content.append(contentsOf: buffer.prefix(bytesRead))
76
+ continue
77
+ }
78
+
79
+ let chunkEnd = range.location
80
+ let length = chunkEnd - chunkStart
81
+ bytesSeen = chunkEnd
82
+
83
+ // Ignore preamble
84
+ if chunkStart > 0 {
85
+ let startIndex = content.index(content.startIndex, offsetBy: chunkStart)
86
+ let endIndex = content.index(startIndex, offsetBy: length)
87
+ let chunk = Data(content[startIndex..<endIndex])
88
+ emitChunk(chunk, headers: currentHeaders, callback: callback, done: isCloseDelimiter)
89
+ currentHeaders = nil
90
+ }
91
+
92
+ if isCloseDelimiter {
93
+ return true
94
+ }
95
+
96
+ chunkStart = chunkEnd + delimiter.count
97
+
98
+ if delimiter == firstDelimiter {
99
+ delimiter = restDelimiter
100
+ }
101
+ }
102
+ }
103
+
104
+ private func parseHeadersIfFound(
105
+ in content: Data,
106
+ range: NSRange,
107
+ chunkStart: Int
108
+ ) -> [String: Any]? {
109
+ let marker = Data("\(Constants.crlf)\(Constants.crlf)".utf8)
110
+ let range = content.range(of: marker, options: [], in: range)
111
+ guard range.location != NSNotFound else {
112
+ return nil
113
+ }
114
+
115
+ let startIndex = content.index(content.startIndex, offsetBy: chunkStart)
116
+ let endIndex = content.index(content.startIndex, offsetBy: range.location)
117
+ let data = content[startIndex..<endIndex]
118
+ return parseHeaders(Data(data))
119
+ }
120
+
121
+ private func parseHeaders(_ data: Data) -> [String: Any] {
122
+ guard let text = String(data: data, encoding: .utf8) else {
123
+ return [:]
124
+ }
125
+
126
+ return text.components(separatedBy: Constants.crlf)
127
+ .compactMap { line -> (String, String)? in
128
+ guard let colonRange = line.range(of: ":") else {
129
+ return nil
130
+ }
131
+ let key = String(line[..<colonRange.lowerBound])
132
+ let value = String(line[colonRange.upperBound...])
133
+ .trimmingCharacters(in: .whitespacesAndNewlines)
134
+ return (key, value)
135
+ }
136
+ .reduce(into: [String: Any]()) { headers, valueTuple in
137
+ headers[valueTuple.0] = valueTuple.1
138
+ }
139
+ }
140
+
141
+ private func emitChunk(
142
+ _ data: Data,
143
+ headers: [String: Any]?,
144
+ callback: @escaping MultipartCallback,
145
+ done: Bool
146
+ ) {
147
+ let marker = Data("\(Constants.crlf)\(Constants.crlf)".utf8)
148
+ guard let range = data.range(of: marker) else {
149
+ callback(nil, data, done)
150
+ return
151
+ }
152
+
153
+ let bodyData = data.subdata(in: range.upperBound..<data.endIndex)
154
+
155
+ if let headers {
156
+ callback(headers, bodyData, done)
157
+ return
158
+ }
159
+
160
+ let headersData = data.subdata(in: data.startIndex..<range.lowerBound)
161
+ callback(parseHeaders(headersData), bodyData, done)
162
+ }
163
+ }
164
+
165
+ private extension Data {
166
+ func range(of data: Data, options: Data.SearchOptions = [], in range: NSRange) -> NSRange {
167
+ return (self as NSData).range(of: data, options: options, in: range)
168
+ }
169
+ }
@@ -0,0 +1,148 @@
1
+ // Copyright © 2019 650 Industries. All rights reserved.
2
+
3
+ import Foundation
4
+
5
+ /**
6
+ * Derivation of ParameterParser class in the apache commons file upload
7
+ * project: https://commons.apache.org/proper/commons-fileupload/
8
+ *
9
+ * A simple parser intended to parse sequences of name/value pairs.
10
+ *
11
+ * Parameter values are expected to be enclosed in quotes if they
12
+ * contain unsafe characters, such as '=' characters or separators.
13
+ * Parameter values are optional and can be omitted.
14
+ */
15
+ public final class UpdatesParameterParser {
16
+ private enum Constants {
17
+ static let quote: Character = "\""
18
+ static let backslash: Character = "\\"
19
+ static let equals: Character = "="
20
+ }
21
+
22
+ private var parameterString: String = ""
23
+ private var currentPosition: Int = 0
24
+ private var tokenStart: Int = 0
25
+ private var tokenEnd: Int = 0
26
+
27
+ public init() {}
28
+
29
+ public func parseParameterString(_ parameterString: String, withDelimiter delimiter: Character) -> [String: Any] {
30
+ self.parameterString = parameterString
31
+ self.currentPosition = 0
32
+
33
+ var params = [String: Any]()
34
+
35
+ let delimiterSet = CharacterSet(charactersIn: String(delimiter))
36
+ let delimiterAndEqualsSet = CharacterSet(charactersIn: String(delimiter) + String(Constants.equals))
37
+
38
+ while hasChar() {
39
+ let paramName = parseToken(withTerminators: delimiterAndEqualsSet)
40
+ var paramValue: Any?
41
+
42
+ if hasChar() && currentChar() == Constants.equals {
43
+ currentPosition += 1 // skip '='
44
+ paramValue = parseQuotedToken(withTerminators: delimiterSet)
45
+ }
46
+
47
+ if hasChar() && currentChar() == delimiter {
48
+ currentPosition += 1 // skip separator
49
+ }
50
+
51
+ if let paramName = paramName, !paramName.isEmpty {
52
+ params[paramName] = paramValue ?? NSNull()
53
+ }
54
+ }
55
+
56
+ return params
57
+ }
58
+
59
+ private func getToken(quoted: Bool) -> String? {
60
+ guard let range = extractTokenRange(quoted: quoted) else { return nil }
61
+ return String(parameterString[range])
62
+ }
63
+
64
+ private func extractTokenRange(quoted: Bool) -> Range<String.Index>? {
65
+ var start = tokenStart
66
+ var end = tokenEnd
67
+
68
+ while start < end && parameterString[stringIndex(at: start)].isWhitespace {
69
+ start += 1
70
+ }
71
+
72
+ while end > start && parameterString[stringIndex(at: end - 1)].isWhitespace {
73
+ end -= 1
74
+ }
75
+
76
+ if quoted && (end - start) >= 2 {
77
+ let startChar = parameterString[stringIndex(at: start)]
78
+ let endChar = parameterString[stringIndex(at: end - 1)]
79
+ if startChar == Constants.quote && endChar == Constants.quote {
80
+ start += 1
81
+ end -= 1
82
+ }
83
+ }
84
+
85
+ guard end > start else {
86
+ return nil
87
+ }
88
+
89
+ let startIndex = stringIndex(at: start)
90
+ let endIndex = stringIndex(at: end)
91
+ return startIndex..<endIndex
92
+ }
93
+
94
+ private func parseToken(withTerminators terminators: CharacterSet) -> String? {
95
+ setTokenBounds()
96
+
97
+ while hasChar() {
98
+ let char = currentChar()
99
+ guard let scalar = char.unicodeScalars.first, !terminators.contains(scalar) else {
100
+ break
101
+ }
102
+ tokenEnd += 1
103
+ currentPosition += 1
104
+ }
105
+
106
+ return getToken(quoted: false)
107
+ }
108
+
109
+ private func parseQuotedToken(withTerminators terminators: CharacterSet) -> String? {
110
+ setTokenBounds()
111
+ var quoted = false
112
+ var charEscaped = false
113
+
114
+ while hasChar() {
115
+ let char = currentChar()
116
+
117
+ if !quoted, let scalar = char.unicodeScalars.first, terminators.contains(scalar) {
118
+ break
119
+ }
120
+
121
+ if !charEscaped && char == Constants.quote {
122
+ quoted.toggle()
123
+ }
124
+ charEscaped = (!charEscaped && char == Constants.backslash)
125
+ tokenEnd += 1
126
+ currentPosition += 1
127
+ }
128
+
129
+ return getToken(quoted: true)
130
+ }
131
+
132
+ private func setTokenBounds() {
133
+ tokenStart = currentPosition
134
+ tokenEnd = currentPosition
135
+ }
136
+
137
+ private func hasChar() -> Bool {
138
+ currentPosition < parameterString.count
139
+ }
140
+
141
+ private func currentChar() -> Character {
142
+ parameterString[stringIndex(at: currentPosition)]
143
+ }
144
+
145
+ private func stringIndex(at position: Int) -> String.Index {
146
+ parameterString.index(parameterString.startIndex, offsetBy: position)
147
+ }
148
+ }
@@ -0,0 +1,142 @@
1
+ // Copyright (c) 2020 650 Industries, Inc. All rights reserved.
2
+
3
+ import XCTest
4
+ @testable import EXUpdates
5
+
6
+ /**
7
+ * Tests for UpdatesMultipartStreamReader
8
+ */
9
+ class UpdatesMultipartStreamReaderSpec: XCTestCase {
10
+ func testSimpleCase() {
11
+ let response = "preamble, should be ignored\r\n" +
12
+ "--sample_boundary\r\n" +
13
+ "Content-Type: application/json; charset=utf-8\r\n" +
14
+ "Content-Length: 2\r\n\r\n" +
15
+ "{}\r\n" +
16
+ "--sample_boundary--\r\n" +
17
+ "epilogue, should be ignored"
18
+
19
+ let inputStream = InputStream(data: response.data(using: .utf8)!)
20
+ let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary")
21
+
22
+ var count = 0
23
+ let success = reader.readAllParts { headers, content, done in
24
+ XCTAssertTrue(done)
25
+ XCTAssertEqual(headers?["Content-Type"] as? String, "application/json; charset=utf-8")
26
+ XCTAssertEqual(String(data: content!, encoding: .utf8), "{}")
27
+ count += 1
28
+ }
29
+
30
+ XCTAssertTrue(success)
31
+ XCTAssertEqual(count, 1)
32
+ }
33
+
34
+ func testMultipleParts() {
35
+ let response = "preamble, should be ignored\r\n" +
36
+ "--sample_boundary\r\n" +
37
+ "1\r\n" +
38
+ "--sample_boundary\r\n" +
39
+ "2\r\n" +
40
+ "--sample_boundary\r\n" +
41
+ "3\r\n" +
42
+ "--sample_boundary--\r\n" +
43
+ "epilogue, should be ignored"
44
+
45
+ let inputStream = InputStream(data: response.data(using: .utf8)!)
46
+ let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary")
47
+
48
+ var count = 0
49
+ let expectedContents = ["1", "2", "3"]
50
+
51
+ let success = reader.readAllParts { _, content, done in
52
+ if count < expectedContents.count {
53
+ let expectedContent = expectedContents[count]
54
+ XCTAssertEqual(String(data: content!, encoding: .utf8), expectedContent)
55
+ XCTAssertEqual(done, count == expectedContents.count - 1)
56
+ count += 1
57
+ }
58
+ }
59
+
60
+ XCTAssertTrue(success)
61
+ XCTAssertEqual(count, 3)
62
+ }
63
+
64
+ func testNoDelimiter() {
65
+ let response = "content with no delimiter"
66
+
67
+ guard let responseData = response.data(using: .utf8) else {
68
+ XCTFail("Failed to convert response to UTF-8 data")
69
+ return
70
+ }
71
+ let inputStream = InputStream(data: responseData)
72
+ let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary")
73
+
74
+ var count = 0
75
+ let success = reader.readAllParts { _, _, _ in
76
+ count += 1
77
+ }
78
+
79
+ XCTAssertFalse(success)
80
+ XCTAssertEqual(count, 0)
81
+ }
82
+
83
+ func testEmptyContent() {
84
+ let response = "--sample_boundary\r\n" +
85
+ "Content-Type: application/json\r\n" +
86
+ "\r\n" +
87
+ "\r\n" +
88
+ "--sample_boundary--\r\n"
89
+
90
+ guard let responseData = response.data(using: .utf8) else {
91
+ XCTFail("Failed to convert response to UTF-8 data")
92
+ return
93
+ }
94
+ let inputStream = InputStream(data: responseData)
95
+ let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary")
96
+
97
+ var count = 0
98
+ let success = reader.readAllParts { headers, content, done in
99
+ XCTAssertTrue(done)
100
+ XCTAssertEqual(headers?["Content-Type"] as? String, "application/json")
101
+ guard let contentData = content else {
102
+ XCTFail("Content should not be nil")
103
+ return
104
+ }
105
+ XCTAssertEqual(String(data: contentData, encoding: .utf8), "")
106
+ count += 1
107
+ }
108
+
109
+ XCTAssertTrue(success)
110
+ XCTAssertEqual(count, 1)
111
+ }
112
+
113
+ func testFirstBoundaryAsBoundary() {
114
+ let response = "--sample_boundary\r\n" +
115
+ "Content-Type: application/json\r\n" +
116
+ "\r\n" +
117
+ "{}\r\n" +
118
+ "--sample_boundary--\r\n"
119
+
120
+ guard let responseData = response.data(using: .utf8) else {
121
+ XCTFail("Failed to convert response to UTF-8 data")
122
+ return
123
+ }
124
+ let inputStream = InputStream(data: responseData)
125
+ let reader = UpdatesMultipartStreamReader(inputStream: inputStream, boundary: "sample_boundary")
126
+
127
+ var count = 0
128
+ let success = reader.readAllParts { headers, content, done in
129
+ XCTAssertTrue(done)
130
+ XCTAssertEqual(headers?["Content-Type"] as? String, "application/json")
131
+ guard let contentData = content else {
132
+ XCTFail("Content should not be nil")
133
+ return
134
+ }
135
+ XCTAssertEqual(String(data: contentData, encoding: .utf8), "{}")
136
+ count += 1
137
+ }
138
+
139
+ XCTAssertTrue(success)
140
+ XCTAssertEqual(count, 1)
141
+ }
142
+ }
@@ -0,0 +1,53 @@
1
+ // Copyright (c) 2020 650 Industries, Inc. All rights reserved.
2
+
3
+ import XCTest
4
+ @testable import EXUpdates
5
+
6
+ class UpdatesParameterParserSpec: XCTestCase {
7
+ func testParameterParser() {
8
+ let testCases: [(String, [String: Any])] = [
9
+ ("", [:]),
10
+ ("test; test1 = stuff ; test2 = \"stuff; stuff\"; test3=\"stuff",
11
+ ["test": NSNull(), "test1": "stuff", "test2": "stuff; stuff", "test3": "\"stuff"]),
12
+ (" test ; test1=stuff ; ; test2=; test3; ",
13
+ ["test": NSNull(), "test1": "stuff", "test2": NSNull(), "test3": NSNull()]),
14
+ (" test", ["test": NSNull()]),
15
+ (" ", [:]),
16
+ (" = stuff ", [:]),
17
+ ("text/plain; Charset=UTF-8", ["text/plain": NSNull(), "Charset": "UTF-8"]),
18
+ ("param = \"stuff\\\"; more stuff\"", ["param": "stuff\\\"; more stuff"]),
19
+ ("param = \"stuff\\\\\"; anotherparam", ["param": "stuff\\\\", "anotherparam": NSNull()]),
20
+ ("foo/bar; param=\"baz=bat\"", ["foo/bar": NSNull(), "param": "baz=bat"]),
21
+
22
+ // Expo-specific tests
23
+ ("multipart/mixed; boundary=BbC04y", ["multipart/mixed": NSNull(), "boundary": "BbC04y"]),
24
+ ("form-data; name=\"manifest\"; filename=\"hello2\"", ["form-data": NSNull(), "name": "manifest", "filename": "hello2"])
25
+ ]
26
+
27
+ let parser = UpdatesParameterParser()
28
+
29
+ for (parameterString, expectedDictionary) in testCases {
30
+ let parameters = parser.parseParameterString(parameterString, withDelimiter: ";")
31
+
32
+ XCTAssertEqual(parameters.count, expectedDictionary.count,
33
+ "Parameter count mismatch for: '\(parameterString)'")
34
+
35
+ for (key, expectedValue) in expectedDictionary {
36
+ XCTAssertTrue(parameters.keys.contains(key),
37
+ "Missing key '\(key)' for: '\(parameterString)'")
38
+
39
+ let actualValue = parameters[key]
40
+
41
+ if expectedValue is NSNull && actualValue is NSNull {
42
+ continue
43
+ } else if let expectedString = expectedValue as? String,
44
+ let actualString = actualValue as? String {
45
+ XCTAssertEqual(actualString, expectedString,
46
+ "Value mismatch for key '\(key)' in: '\(parameterString)'")
47
+ } else {
48
+ XCTFail("Type mismatch for key '\(key)' in: '\(parameterString)'. Expected: \(type(of: expectedValue)), Actual: \(type(of: actualValue ?? "nil"))")
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "29.0.6",
3
+ "version": "29.0.7",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -39,13 +39,13 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@expo/code-signing-certificates": "0.0.5",
42
- "@expo/plist": "^0.4.4",
42
+ "@expo/plist": "^0.4.5",
43
43
  "@expo/spawn-async": "^1.7.2",
44
44
  "arg": "4.1.0",
45
45
  "chalk": "^4.1.2",
46
46
  "debug": "^4.3.4",
47
- "expo-eas-client": "~1.0.4",
48
- "expo-manifests": "~1.0.5",
47
+ "expo-eas-client": "~1.0.5",
48
+ "expo-manifests": "~1.0.6",
49
49
  "expo-structured-headers": "~5.0.0",
50
50
  "expo-updates-interface": "~2.0.0",
51
51
  "getenv": "^2.0.0",
@@ -73,5 +73,5 @@
73
73
  "react": "*",
74
74
  "react-native": "*"
75
75
  },
76
- "gitHead": "5e15f3a5d68bc213ad36d59113d725fdea4f3411"
76
+ "gitHead": "1dafdaa317c182191909a94a355c8b08a2654783"
77
77
  }
@@ -1,20 +0,0 @@
1
- // Copyright © 2019 650 Industries. All rights reserved.
2
-
3
- #import <Foundation/Foundation.h>
4
-
5
- NS_ASSUME_NONNULL_BEGIN
6
-
7
- typedef void (^EXMultipartCallback)(NSDictionary * _Nullable headers, NSData * _Nullable content, BOOL done);
8
-
9
- /**
10
- * Fork of {@link RCTMultipartStreamReader} that doesn't necessarily
11
- * expect a preamble (first boundary is not necessarily preceded by CRLF).
12
- */
13
- @interface EXUpdatesMultipartStreamReader : NSObject
14
-
15
- - (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary;
16
- - (BOOL)readAllPartsWithCompletionCallback:(EXMultipartCallback)callback;
17
-
18
- @end
19
-
20
- NS_ASSUME_NONNULL_END
@@ -1,137 +0,0 @@
1
- // Copyright © 2019 650 Industries. All rights reserved.
2
-
3
- #import <EXUpdates/EXUpdatesMultipartStreamReader.h>
4
-
5
- #define CRLF @"\r\n"
6
-
7
- @implementation EXUpdatesMultipartStreamReader {
8
- __strong NSInputStream *_stream;
9
- __strong NSString *_boundary;
10
- }
11
-
12
- - (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary
13
- {
14
- if (self = [super init]) {
15
- _stream = stream;
16
- _boundary = boundary;
17
- }
18
- return self;
19
- }
20
-
21
- - (NSDictionary<NSString *, NSString *> *)parseHeaders:(NSData *)data
22
- {
23
- NSMutableDictionary *headers = [NSMutableDictionary new];
24
- NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
25
- NSArray<NSString *> *lines = [text componentsSeparatedByString:CRLF];
26
- for (NSString *line in lines) {
27
- NSUInteger location = [line rangeOfString:@":"].location;
28
- if (location == NSNotFound) {
29
- continue;
30
- }
31
- NSString *key = [line substringToIndex:location];
32
- NSString *value = [[line substringFromIndex:location + 1] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
33
- [headers setValue:value forKey:key];
34
- }
35
- return headers;
36
- }
37
-
38
- - (void)emitChunk:(NSData *)data headers:(NSDictionary *)headers callback:(EXMultipartCallback)callback done:(BOOL)done
39
- {
40
- NSData *marker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding];
41
- NSRange range = [data rangeOfData:marker options:0 range:NSMakeRange(0, data.length)];
42
- if (range.location == NSNotFound) {
43
- callback(nil, data, done);
44
- } else if (headers != nil) {
45
- // If headers were parsed already just use that to avoid doing it twice.
46
- NSInteger bodyStart = range.location + marker.length;
47
- NSData *bodyData = [data subdataWithRange:NSMakeRange(bodyStart, data.length - bodyStart)];
48
- callback(headers, bodyData, done);
49
- } else {
50
- NSData *headersData = [data subdataWithRange:NSMakeRange(0, range.location)];
51
- NSInteger bodyStart = range.location + marker.length;
52
- NSData *bodyData = [data subdataWithRange:NSMakeRange(bodyStart, data.length - bodyStart)];
53
- callback([self parseHeaders:headersData], bodyData, done);
54
- }
55
- }
56
-
57
- - (BOOL)readAllPartsWithCompletionCallback:(EXMultipartCallback)callback
58
- {
59
- NSInteger chunkStart = 0;
60
- NSInteger bytesSeen = 0;
61
-
62
- // first delimiter doesn't necessarily need to be preceded by CRLF (boundary can be first thing in body)
63
- NSData *firstDelimiter = [[NSString stringWithFormat:@"--%@%@", _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
64
- NSData *restDelimiter = [[NSString stringWithFormat:@"%@--%@%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
65
- NSData *delimiter = firstDelimiter;
66
-
67
- NSData *closeDelimiter = [[NSString stringWithFormat:@"%@--%@--%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding];
68
- NSMutableData *content = [[NSMutableData alloc] initWithCapacity:1];
69
- NSDictionary *currentHeaders = nil;
70
- NSUInteger currentHeadersLength = 0;
71
-
72
- const NSUInteger bufferLen = 4 * 1024;
73
- uint8_t buffer[bufferLen];
74
-
75
- [_stream open];
76
- while (true) {
77
- BOOL isCloseDelimiter = NO;
78
- // Search only a subset of chunk that we haven't seen before + few bytes
79
- // to allow for the edge case when the delimiter is cut by read call
80
- NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart);
81
- NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart);
82
-
83
- // Check for delimiters.
84
- NSRange range = [content rangeOfData:delimiter options:0 range:remainingBufferRange];
85
- if (range.location == NSNotFound) {
86
- isCloseDelimiter = YES;
87
- range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange];
88
- }
89
-
90
- if (range.location == NSNotFound) {
91
- if (currentHeaders == nil) {
92
- // Check for the headers delimiter.
93
- NSData *headersMarker = [CRLF CRLF dataUsingEncoding:NSUTF8StringEncoding];
94
- NSRange headersRange = [content rangeOfData:headersMarker options:0 range:remainingBufferRange];
95
- if (headersRange.location != NSNotFound) {
96
- NSData *headersData = [content subdataWithRange:NSMakeRange(chunkStart, headersRange.location - chunkStart)];
97
- currentHeadersLength = headersData.length;
98
- currentHeaders = [self parseHeaders:headersData];
99
- }
100
- }
101
-
102
- bytesSeen = content.length;
103
- NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen];
104
- if (bytesRead <= 0 || _stream.streamError) {
105
- [_stream close];
106
- return NO;
107
- }
108
- [content appendBytes:buffer length:bytesRead];
109
- continue;
110
- }
111
-
112
- NSInteger chunkEnd = range.location;
113
- NSInteger length = chunkEnd - chunkStart;
114
- bytesSeen = chunkEnd;
115
-
116
- // Ignore preamble
117
- if (chunkStart > 0) {
118
- NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)];
119
- [self emitChunk:chunk headers:currentHeaders callback:callback done:isCloseDelimiter];
120
- currentHeaders = nil;
121
- currentHeadersLength = 0;
122
- }
123
-
124
- if (isCloseDelimiter) {
125
- [_stream close];
126
- return YES;
127
- }
128
-
129
- chunkStart = chunkEnd + delimiter.length;
130
-
131
- if (delimiter == firstDelimiter) {
132
- delimiter = restDelimiter;
133
- }
134
- }
135
- }
136
-
137
- @end
@@ -1,23 +0,0 @@
1
- // Copyright © 2019 650 Industries. All rights reserved.
2
-
3
- #import <Foundation/Foundation.h>
4
-
5
- NS_ASSUME_NONNULL_BEGIN
6
-
7
- /**
8
- * Derivation of ParameterParser class in the apache commons file upload
9
- * project: https://commons.apache.org/proper/commons-fileupload/
10
- *
11
- * A simple parser intended to parse sequences of name/value pairs.
12
- *
13
- * Parameter values are expected to be enclosed in quotes if they
14
- * contain unsafe characters, such as '=' characters or separators.
15
- * Parameter values are optional and can be omitted.
16
- */
17
- @interface EXUpdatesParameterParser : NSObject
18
-
19
- - (NSDictionary *)parseParameterString:(NSString *)parameterString withDelimiter:(unichar)delimiter;
20
-
21
- @end
22
-
23
- NS_ASSUME_NONNULL_END
@@ -1,143 +0,0 @@
1
- // Copyright © 2019 650 Industries. All rights reserved.
2
-
3
- #import <EXUpdates/EXUpdatesParameterParser.h>
4
-
5
- NS_ASSUME_NONNULL_BEGIN
6
-
7
- @implementation EXUpdatesParameterParser {
8
- // String to be parsed.
9
- NSString *_parameterString;
10
-
11
- // Current position in the string.
12
- int _currentPosition;
13
-
14
- // Start of a token.
15
- int _i1;
16
-
17
- // End of a token.
18
- int _i2;
19
- }
20
-
21
- /**
22
- * A helper method to process the parsed token. This method removes
23
- * leading and trailing blanks as well as enclosing quotation marks,
24
- * when necessary.
25
- */
26
- - (nullable NSString *)getTokenWithQuoted:(BOOL)quoted {
27
- // Trim leading white spaces
28
- while ((_i1 < _i2) && [[NSCharacterSet whitespaceCharacterSet] characterIsMember:[_parameterString characterAtIndex:_i1]]) {
29
- _i1++;
30
- }
31
-
32
- // Trim trailing white spaces
33
- while ((_i2 > _i1) && [[NSCharacterSet whitespaceCharacterSet] characterIsMember:[_parameterString characterAtIndex:(_i2 - 1)]]) {
34
- _i2--;
35
- }
36
-
37
- // Strip away quotation marks if necessary
38
- if (quoted
39
- && ((_i2 - _i1) >= 2)
40
- && ([_parameterString characterAtIndex:_i1] == '"')
41
- && ([_parameterString characterAtIndex:(_i2 - 1)] == '"')) {
42
- _i1++;
43
- _i2--;
44
- }
45
-
46
- NSString *result = nil;
47
- if (_i2 > _i1) {
48
- result = [_parameterString substringWithRange:NSMakeRange(_i1, _i2 - _i1)];
49
- }
50
- return result;
51
- }
52
-
53
- /**
54
- * Parses out a token until any of the given terminators
55
- * is encountered.
56
- */
57
- - (NSString *)parseTokenWithTerminators:(NSCharacterSet *)terminators {
58
- unichar ch;
59
- _i1 = _currentPosition;
60
- _i2 = _currentPosition;
61
- while ([self hasChar]) {
62
- ch = [_parameterString characterAtIndex:_currentPosition];
63
- if ([terminators characterIsMember:ch]) {
64
- break;
65
- }
66
- _i2++;
67
- _currentPosition++;
68
- }
69
- return [self getTokenWithQuoted:false];
70
- }
71
-
72
- /**
73
- * Parses out a token until any of the given terminators
74
- * is encountered outside the quotation marks.
75
- */
76
- - (NSString *)parseQuotedTokenWithTerminators:(NSCharacterSet *)terminators {
77
- unichar ch;
78
- _i1 = _currentPosition;
79
- _i2 = _currentPosition;
80
- BOOL quoted = false;
81
- BOOL charEscaped = false;
82
-
83
- while ([self hasChar]) {
84
- ch = [_parameterString characterAtIndex:_currentPosition];
85
- if (!quoted && [terminators characterIsMember:ch]) {
86
- break;
87
- }
88
- if (!charEscaped && ch == '"') {
89
- quoted = !quoted;
90
- }
91
- charEscaped = (!charEscaped && ch == '\\');
92
- _i2++;
93
- _currentPosition++;
94
- }
95
-
96
- return [self getTokenWithQuoted:true];
97
- }
98
-
99
- /**
100
- * Are there any characters left to parse?
101
- */
102
- - (BOOL)hasChar {
103
- return _currentPosition < _parameterString.length;
104
- }
105
-
106
- /**
107
- * Extracts a map of name/value pairs from the given string. Names are expected to be unique.
108
- */
109
- - (NSDictionary *)parseParameterString:(NSString *)parameterString withDelimiter:(unichar)delimiter {
110
- _parameterString = parameterString;
111
-
112
- NSMutableDictionary *params = [NSMutableDictionary new];
113
-
114
- NSString *paramName;
115
- NSString *paramValue;
116
-
117
- NSCharacterSet *charSetDelimiter = [NSCharacterSet characterSetWithCharactersInString:[NSString stringWithFormat:@"%C", delimiter]];
118
- NSCharacterSet *charSetDelimiterAndEquals = [NSCharacterSet characterSetWithCharactersInString:[NSString stringWithFormat:@"%C=", delimiter]];
119
-
120
- while ([self hasChar]) {
121
- paramName = [self parseTokenWithTerminators:charSetDelimiterAndEquals];
122
- paramValue = nil;
123
-
124
- if ([self hasChar] && [_parameterString characterAtIndex:_currentPosition] == '=') {
125
- _currentPosition++; // skip '='
126
- paramValue = [self parseQuotedTokenWithTerminators:charSetDelimiter];
127
- }
128
-
129
- if ([self hasChar] && ([_parameterString characterAtIndex:_currentPosition] == delimiter)) {
130
- _currentPosition++; // skip separator
131
- }
132
-
133
- if (paramName != nil && paramName.length > 0) {
134
- [params setValue:(paramValue ?: [NSNull null]) forKey:paramName];
135
- }
136
- }
137
-
138
- return params;
139
- }
140
-
141
- @end
142
-
143
- NS_ASSUME_NONNULL_END
@@ -1,135 +0,0 @@
1
- // Copyright (c) 2020 650 Industries, Inc. All rights reserved.
2
-
3
- #import <XCTest/XCTest.h>
4
-
5
- #import <EXUpdates/EXUpdatesMultipartStreamReader.h>
6
-
7
- /**
8
- * Fork of {@link EXUpdatesMultipartStreamReaderTests}.
9
- */
10
- @interface EXUpdatesMultipartStreamReaderTests : XCTestCase
11
-
12
- @end
13
-
14
- @implementation EXUpdatesMultipartStreamReaderTests
15
-
16
- - (void)testSimpleCase
17
- {
18
- NSString *response =
19
- @"preamble, should be ignored\r\n"
20
- @"--sample_boundary\r\n"
21
- @"Content-Type: application/json; charset=utf-8\r\n"
22
- @"Content-Length: 2\r\n\r\n"
23
- @"{}\r\n"
24
- @"--sample_boundary--\r\n"
25
- @"epilogue, should be ignored";
26
-
27
- NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
28
- EXUpdatesMultipartStreamReader *reader = [[EXUpdatesMultipartStreamReader alloc] initWithInputStream:inputStream
29
- boundary:@"sample_boundary"];
30
- __block NSInteger count = 0;
31
- BOOL success = [reader readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) {
32
- XCTAssertTrue(done);
33
- XCTAssertEqualObjects(headers[@"Content-Type"], @"application/json; charset=utf-8");
34
- XCTAssertEqualObjects([[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding], @"{}");
35
- count++;
36
- }];
37
- XCTAssertTrue(success);
38
- XCTAssertEqual(count, 1);
39
- }
40
-
41
- - (void)testMultipleParts
42
- {
43
- NSString *response =
44
- @"preamble, should be ignored\r\n"
45
- @"--sample_boundary\r\n"
46
- @"1\r\n"
47
- @"--sample_boundary\r\n"
48
- @"2\r\n"
49
- @"--sample_boundary\r\n"
50
- @"3\r\n"
51
- @"--sample_boundary--\r\n"
52
- @"epilogue, should be ignored";
53
-
54
- NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
55
- EXUpdatesMultipartStreamReader *reader = [[EXUpdatesMultipartStreamReader alloc] initWithInputStream:inputStream
56
- boundary:@"sample_boundary"];
57
- __block NSInteger count = 0;
58
- BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, NSData *content, BOOL done) {
59
- count++;
60
- XCTAssertEqual(done, count == 3);
61
- NSString *expectedBody = [NSString stringWithFormat:@"%ld", (long)count];
62
- NSString *actualBody = [[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding];
63
- XCTAssertEqualObjects(actualBody, expectedBody);
64
- }];
65
- XCTAssertTrue(success);
66
- XCTAssertEqual(count, 3);
67
- }
68
-
69
- - (void)testMultiplePartsNoPreamble
70
- {
71
- NSString *response =
72
- @"--sample_boundary\r\n"
73
- @"1\r\n"
74
- @"--sample_boundary\r\n"
75
- @"2\r\n"
76
- @"--sample_boundary\r\n"
77
- @"3\r\n"
78
- @"--sample_boundary--\r\n"
79
- @"epilogue, should be ignored";
80
-
81
- NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
82
- EXUpdatesMultipartStreamReader *reader = [[EXUpdatesMultipartStreamReader alloc] initWithInputStream:inputStream
83
- boundary:@"sample_boundary"];
84
- __block NSInteger count = 0;
85
- BOOL success = [reader readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, NSData *content, BOOL done) {
86
- count++;
87
- XCTAssertEqual(done, count == 3);
88
- NSString *expectedBody = [NSString stringWithFormat:@"%ld", (long)count];
89
- NSString *actualBody = [[NSString alloc] initWithData:content encoding:NSUTF8StringEncoding];
90
- XCTAssertEqualObjects(actualBody, expectedBody);
91
- }];
92
- XCTAssertTrue(success);
93
- XCTAssertEqual(count, 3);
94
- }
95
-
96
- - (void)testNoDelimiter
97
- {
98
- NSString *response = @"Yolo";
99
-
100
- NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
101
- EXUpdatesMultipartStreamReader *reader = [[EXUpdatesMultipartStreamReader alloc] initWithInputStream:inputStream
102
- boundary:@"sample_boundary"];
103
- __block NSInteger count = 0;
104
- BOOL success = [reader readAllPartsWithCompletionCallback:^(
105
- __unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
106
- count++;
107
- }];
108
- XCTAssertFalse(success);
109
- XCTAssertEqual(count, 0);
110
- }
111
-
112
- - (void)testNoCloseDelimiter
113
- {
114
- NSString *response =
115
- @"preamble, should be ignored\r\n"
116
- @"--sample_boundary\r\n"
117
- @"Content-Type: application/json; charset=utf-8\r\n"
118
- @"Content-Length: 2\r\n\r\n"
119
- @"{}\r\n"
120
- @"--sample_boundary\r\n"
121
- @"incomplete message...";
122
-
123
- NSInputStream *inputStream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]];
124
- EXUpdatesMultipartStreamReader *reader = [[EXUpdatesMultipartStreamReader alloc] initWithInputStream:inputStream
125
- boundary:@"sample_boundary"];
126
- __block NSInteger count = 0;
127
- BOOL success = [reader readAllPartsWithCompletionCallback:^(
128
- __unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) {
129
- count++;
130
- }];
131
- XCTAssertFalse(success);
132
- XCTAssertEqual(count, 1);
133
- }
134
-
135
- @end
@@ -1,44 +0,0 @@
1
- // Copyright (c) 2020 650 Industries, Inc. All rights reserved.
2
-
3
- #import <XCTest/XCTest.h>
4
-
5
- #import <EXUpdates/EXUpdatesParameterParser.h>
6
-
7
- @interface EXUpdatesParameterParserTests : XCTestCase
8
- @end
9
-
10
- @implementation EXUpdatesParameterParserTests
11
-
12
- - (void)testParameterParser
13
- {
14
- NSArray<NSArray *> *testCases = @[
15
- @[@"", @{}],
16
- @[@"test; test1 = stuff ; test2 = \"stuff; stuff\"; test3=\"stuff",
17
- @{@"test": [NSNull null], @"test1": @"stuff", @"test2": @"stuff; stuff", @"test3": @"\"stuff"}
18
- ],
19
- @[@" test ; test1=stuff ; ; test2=; test3; ",
20
- @{@"test": [NSNull null], @"test1": @"stuff", @"test2": [NSNull null], @"test3": [NSNull null]}
21
- ],
22
- @[@" test", @{@"test": [NSNull null]}],
23
- @[@" ", @{}],
24
- @[@" = stuff ", @{}],
25
- @[@"text/plain; Charset=UTF-8", @{@"text/plain": [NSNull null], @"Charset": @"UTF-8"}],
26
- @[@"param = \"stuff\\\"; more stuff\"", @{@"param": @"stuff\\\"; more stuff"}],
27
- @[@"param = \"stuff\\\\\"; anotherparam", @{@"param": @"stuff\\\\", @"anotherparam": [NSNull null]}],
28
- @[@"foo/bar; param=\"baz=bat\"", @{@"foo/bar": [NSNull null], @"param": @"baz=bat"}],
29
-
30
- // Expo-specific tests
31
- @[@"multipart/mixed; boundary=BbC04y", @{@"multipart/mixed": [NSNull null], @"boundary": @"BbC04y"}],
32
- @[@"form-data; name=\"manifest\"; filename=\"hello2\"", @{@"form-data": [NSNull null], @"name": @"manifest", @"filename": @"hello2"}],
33
- ];
34
-
35
- for (NSArray *testCase in testCases) {
36
- NSString *parameterString = testCase[0];
37
- NSDictionary *expectedDictionary = testCase[1];
38
-
39
- NSDictionary *parameters = [[EXUpdatesParameterParser new] parseParameterString:parameterString withDelimiter:';'];
40
- XCTAssertTrue([expectedDictionary isEqualToDictionary:parameters], @"result did not match expected");
41
- }
42
- }
43
-
44
- @end