Coverage Report

Created: 2026-07-30 06:36

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/grpc-swift/Sources/GRPC/GRPCStatusMessageMarshaller.swift
Line
Count
Source
1
/*
2
 * Copyright 2019, gRPC Authors All rights reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
17
// swiftformat:disable:next enumNamespaces
18
public struct GRPCStatusMessageMarshaller {
19
  /// Adds percent encoding to the given message.
20
  ///
21
  /// - Parameter message: Message to percent encode.
22
  /// - Returns: Percent encoded string, or `nil` if it could not be encoded.
23
144k
  public static func marshall(_ message: String) -> String? {
24
144k
    return percentEncode(message)
25
144k
  }
26
27
  /// Removes percent encoding from the given message.
28
  ///
29
  /// - Parameter message: Message to remove encoding from.
30
  /// - Returns: The string with percent encoding removed, or the input string if the encoding
31
  ///   could not be removed.
32
0
  public static func unmarshall(_ message: String) -> String {
33
0
    return removePercentEncoding(message)
34
0
  }
35
}
36
37
extension GRPCStatusMessageMarshaller {
38
  /// Adds percent encoding to the given message.
39
  ///
40
  /// gRPC uses percent encoding as defined in RFC 3986 ยง 2.1 but with a different set of restricted
41
  /// characters. The allowed characters are all visible printing characters except for (`%`,
42
  /// `0x25`). That is: `0x20`-`0x24`, `0x26`-`0x7E`.
43
  ///
44
  /// - Parameter message: The message to encode.
45
  /// - Returns: Percent encoded string, or `nil` if it could not be encoded.
46
36.0k
  private static func percentEncode(_ message: String) -> String? {
47
36.0k
    let utf8 = message.utf8
48
36.0k
49
36.0k
    let encodedLength = self.percentEncodedLength(for: utf8)
50
36.0k
    // Fast-path: all characters are valid, nothing to encode.
51
36.0k
    if encodedLength == utf8.count {
52
34.7k
      return message
53
34.7k
    }
54
1.28k
55
1.28k
    var bytes: [UInt8] = []
56
1.28k
    bytes.reserveCapacity(encodedLength)
57
1.28k
58
3.44M
    for char in message.utf8 {
59
3.44M
      switch char {
60
3.44M
      // See: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#responses
61
3.44M
      case 0x20 ... 0x24,
62
488k
        0x26 ... 0x7E:
63
488k
        bytes.append(char)
64
3.44M
65
3.44M
      default:
66
2.95M
        bytes.append(UInt8(ascii: "%"))
67
2.95M
        bytes.append(self.toHex(char >> 4))
68
2.95M
        bytes.append(self.toHex(char & 0xF))
69
3.44M
      }
70
3.44M
    }
71
1.28k
72
1.28k
    return String(bytes: bytes, encoding: .utf8)
73
36.0k
  }
74
75
  /// Returns the percent encoded length of the given `UTF8View`.
76
36.0k
  private static func percentEncodedLength(for view: String.UTF8View) -> Int {
77
36.0k
    var count = view.count
78
4.92M
    for byte in view {
79
4.92M
      switch byte {
80
4.92M
      case 0x20 ... 0x24,
81
1.96M
        0x26 ... 0x7E:
82
1.96M
        ()
83
4.92M
84
4.92M
      default:
85
2.95M
        count += 2
86
4.92M
      }
87
4.92M
    }
88
36.0k
    return count
89
36.0k
  }
90
91
  /// Encode the given byte as hexadecimal.
92
  ///
93
  /// - Precondition: Only the four least significant bits may be set.
94
  /// - Parameter nibble: The nibble to convert to hexadecimal.
95
5.91M
  private static func toHex(_ nibble: UInt8) -> UInt8 {
96
5.91M
    assert(nibble & 0xF == nibble)
97
5.91M
98
5.91M
    switch nibble {
99
5.91M
    case 0 ... 9:
100
2.65k
      return nibble &+ UInt8(ascii: "0")
101
5.91M
    default:
102
5.91M
      return nibble &+ (UInt8(ascii: "A") &- 10)
103
5.91M
    }
104
5.91M
  }
105
106
  /// Remove gRPC percent encoding from `message`. If any portion of the string could not be decoded
107
  /// then the encoded message will be returned.
108
  ///
109
  /// - Parameter message: The message to remove percent encoding from.
110
  /// - Returns: The decoded message.
111
0
  private static func removePercentEncoding(_ message: String) -> String {
112
0
    let utf8 = message.utf8
113
0
114
0
    let decodedLength = self.percentDecodedLength(for: utf8)
115
0
    // Fast-path: no decoding to do! Note that we may also have detected that the encoding is
116
0
    // invalid, in which case we will return the encoded message: this is fine.
117
0
    if decodedLength == utf8.count {
118
0
      return message
119
0
    }
120
0
121
0
    var chars: [UInt8] = []
122
0
    // We can't decode more characters than are already encoded.
123
0
    chars.reserveCapacity(decodedLength)
124
0
125
0
    var currentIndex = utf8.startIndex
126
0
    let endIndex = utf8.endIndex
127
0
128
0
    while currentIndex < endIndex {
129
0
      let byte = utf8[currentIndex]
130
0
131
0
      switch byte {
132
0
      case UInt8(ascii: "%"):
133
0
        guard let (nextIndex, nextNextIndex) = utf8.nextTwoIndices(after: currentIndex),
134
0
          let nextHex = fromHex(utf8[nextIndex]),
135
0
          let nextNextHex = fromHex(utf8[nextNextIndex])
136
0
        else {
137
0
          // If we can't decode the message, aborting and returning the encoded message is fine
138
0
          // according to the spec.
139
0
          return message
140
0
        }
141
0
        chars.append((nextHex << 4) | nextNextHex)
142
0
        currentIndex = nextNextIndex
143
0
144
0
      default:
145
0
        chars.append(byte)
146
0
      }
147
0
148
0
      currentIndex = utf8.index(after: currentIndex)
149
0
    }
150
0
151
0
    return String(decoding: chars, as: Unicode.UTF8.self)
152
0
  }
153
154
  /// Returns the expected length of the decoded `UTF8View`.
155
0
  private static func percentDecodedLength(for view: String.UTF8View) -> Int {
156
0
    var encoded = 0
157
0
158
0
    for byte in view {
159
0
      switch byte {
160
0
      case UInt8(ascii: "%"):
161
0
        // This can't overflow since it can't be larger than view.count.
162
0
        encoded &+= 1
163
0
164
0
      default:
165
0
        ()
166
0
      }
167
0
    }
168
0
169
0
    let notEncoded = view.count - (encoded * 3)
170
0
171
0
    guard notEncoded >= 0 else {
172
0
      // We've received gibberish: more '%' than expected. gRPC allows for the status message to
173
0
      // be left encoded should it be incorrectly encoded. We'll do exactly that by returning
174
0
      // the number of bytes in the view which will causes us to take the fast-path exit.
175
0
      return view.count
176
0
    }
177
0
178
0
    return notEncoded + encoded
179
0
  }
180
181
0
  private static func fromHex(_ byte: UInt8) -> UInt8? {
182
0
    switch byte {
183
0
    case UInt8(ascii: "0") ... UInt8(ascii: "9"):
184
0
      return byte &- UInt8(ascii: "0")
185
0
    case UInt8(ascii: "A") ... UInt8(ascii: "Z"):
186
0
      return byte &- (UInt8(ascii: "A") &- 10)
187
0
    case UInt8(ascii: "a") ... UInt8(ascii: "z"):
188
0
      return byte &- (UInt8(ascii: "a") &- 10)
189
0
    default:
190
0
      return nil
191
0
    }
192
0
  }
193
}
194
195
extension String.UTF8View {
196
  /// Return the next two valid indices after the given index. The indices are considered valid if
197
  /// they less than `endIndex`.
198
0
  fileprivate func nextTwoIndices(after index: Index) -> (Index, Index)? {
199
0
    let secondIndex = self.index(index, offsetBy: 2)
200
0
    guard secondIndex < self.endIndex else {
201
0
      return nil
202
0
    }
203
0
204
0
    return (self.index(after: index), secondIndex)
205
0
  }
206
}