1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one or more
3 * contributor license agreements. See the NOTICE file distributed with
4 * this work for additional information regarding copyright ownership.
5 * The ASF licenses this file to You under the Apache License, Version 2.0
6 * (the "License"); you may not use this file except in compliance with
7 * the License. You may obtain a copy of the License at
8 *
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18 package org.apache.commons.codec.net;
19
20 import java.nio.ByteBuffer;
21 import java.util.BitSet;
22
23 import org.apache.commons.codec.BinaryDecoder;
24 import org.apache.commons.codec.BinaryEncoder;
25 import org.apache.commons.codec.DecoderException;
26 import org.apache.commons.codec.EncoderException;
27
28 /**
29 * Implements the Percent-Encoding scheme, as described in HTTP 1.1 specification. For extensibility, an array of
30 * special US-ASCII characters can be specified in order to perform proper URI encoding for the different parts
31 * of the URI.
32 * <p>
33 * This class is immutable. It is also thread-safe besides using BitSet which is not thread-safe, but its public
34 * interface only call the access
35 * </p>
36 *
37 * @see <a href="https://tools.ietf.org/html/rfc3986#section-2.1">Percent-Encoding</a>
38 * @since 1.12
39 */
40 public class PercentCodec implements BinaryEncoder, BinaryDecoder {
41
42 /**
43 * The escape character used by the Percent-Encoding in order to introduce an encoded character.
44 */
45 private static final byte ESCAPE_CHAR = '%';
46
47 /**
48 * The plus character used to encode spaces when plusForSpace is true.
49 */
50 private static final byte PLUS_CHAR = '+';
51
52 /**
53 * The bit set used to store the character that should be always encoded.
54 */
55 private final BitSet alwaysEncodeChars = new BitSet();
56
57 /**
58 * The flag defining if the space character should be encoded as '+'.
59 */
60 private final boolean plusForSpace;
61
62 /**
63 * The minimum and maximum code of the bytes that is inserted in the bit set, used to prevent look-ups
64 */
65 private int alwaysEncodeCharsMin = Integer.MAX_VALUE, alwaysEncodeCharsMax = Integer.MIN_VALUE;
66
67 /**
68 * Constructs a Percent coded that will encode all the non US-ASCII characters using the Percent-Encoding
69 * while it will not encode all the US-ASCII characters, except for character '%' that is used as escape
70 * character for Percent-Encoding.
71 */
72 public PercentCodec() {
73 this.plusForSpace = false;
74 insertAlwaysEncodeChar(ESCAPE_CHAR);
75 }
76
77 /**
78 * Constructs a Percent codec by specifying the characters that belong to US-ASCII that should
79 * always be encoded. The rest US-ASCII characters will not be encoded, except for character '%' that
80 * is used as escape character for Percent-Encoding.
81 *
82 * @param alwaysEncodeChars The unsafe characters that should always be encoded.
83 * @param plusForSpace The flag defining if the space character should be encoded as '+'.
84 */
85 public PercentCodec(final byte[] alwaysEncodeChars, final boolean plusForSpace) {
86 this.plusForSpace = plusForSpace;
87 insertAlwaysEncodeChars(alwaysEncodeChars);
88 if (plusForSpace) {
89 insertAlwaysEncodeChar(PLUS_CHAR);
90 }
91 }
92
93 private boolean canEncode(final byte c) {
94 return !isAsciiChar(c) || inAlwaysEncodeCharsRange(c) && alwaysEncodeChars.get(c);
95 }
96
97 private boolean containsSpace(final byte[] bytes) {
98 for (final byte b : bytes) {
99 if (b == ' ') {
100 return true;
101 }
102 }
103 return false;
104 }
105
106 /**
107 * Decodes bytes encoded with Percent-Encoding based on RFC 3986. The reverse process is performed in order to
108 * decode the encoded characters to Unicode.
109 *
110 * @throws DecoderException Thrown if an invalid percent-encoded sequence is found.
111 */
112 @Override
113 public byte[] decode(final byte[] bytes) throws DecoderException {
114 if (bytes == null) {
115 return null;
116 }
117 final ByteBuffer buffer = ByteBuffer.allocate(expectedDecodingBytes(bytes));
118 for (int i = 0; i < bytes.length; i++) {
119 final byte b = bytes[i];
120 if (b == ESCAPE_CHAR) {
121 try {
122 final int u = Utils.digit16(bytes[++i]);
123 final int l = Utils.digit16(bytes[++i]);
124 buffer.put((byte) ((u << 4) + l));
125 } catch (final ArrayIndexOutOfBoundsException e) {
126 throw new DecoderException("Invalid percent decoding: ", e);
127 }
128 } else if (plusForSpace && b == '+') {
129 buffer.put((byte) ' ');
130 } else {
131 buffer.put(b);
132 }
133 }
134 return buffer.array();
135 }
136
137 /**
138 * Decodes a byte[] Object, whose bytes are encoded with Percent-Encoding.
139 *
140 * @param obj The object to decode.
141 * @return The decoding result byte[] as Object.
142 * @throws DecoderException Thrown if the object is not a byte array.
143 */
144 @Override
145 public Object decode(final Object obj) throws DecoderException {
146 if (obj == null) {
147 return null;
148 }
149 if (obj instanceof byte[]) {
150 return decode((byte[]) obj);
151 }
152 throw new DecoderException("Objects of type " + obj.getClass().getName() + " cannot be Percent decoded");
153 }
154
155 private byte[] doEncode(final byte[] bytes, final int expectedLength, final boolean willEncode) {
156 final ByteBuffer buffer = ByteBuffer.allocate(expectedLength);
157 for (final byte b : bytes) {
158 if (willEncode && canEncode(b)) {
159 byte bb = b;
160 if (bb < 0) {
161 bb = (byte) (256 + bb);
162 }
163 final char hex1 = Utils.hexChar(bb >> 4);
164 final char hex2 = Utils.hexChar(bb);
165 buffer.put(ESCAPE_CHAR);
166 buffer.put((byte) hex1);
167 buffer.put((byte) hex2);
168 } else if (plusForSpace && b == ' ') {
169 buffer.put((byte) '+');
170 } else {
171 buffer.put(b);
172 }
173 }
174 return buffer.array();
175 }
176
177 /**
178 * Percent-Encoding based on RFC 3986. The non US-ASCII characters are encoded, as well as the
179 * US-ASCII characters that are configured to be always encoded.
180 *
181 * @throws EncoderException Thrown if an encoding error occurs.
182 */
183 @Override
184 public byte[] encode(final byte[] bytes) throws EncoderException {
185 if (bytes == null) {
186 return null;
187 }
188 final int expectedEncodingBytes = expectedEncodingBytes(bytes);
189 final boolean willEncode = expectedEncodingBytes != bytes.length;
190 if (willEncode || plusForSpace && containsSpace(bytes)) {
191 return doEncode(bytes, expectedEncodingBytes, willEncode);
192 }
193 return bytes;
194 }
195
196 /**
197 * Encodes an object into using the Percent-Encoding. Only byte[] objects are accepted.
198 *
199 * @param obj The object to encode.
200 * @return The encoding result byte[] as Object.
201 * @throws EncoderException Thrown if the object is not a byte array.
202 */
203 @Override
204 public Object encode(final Object obj) throws EncoderException {
205 if (obj == null) {
206 return null;
207 }
208 if (obj instanceof byte[]) {
209 return encode((byte[]) obj);
210 }
211 throw new EncoderException("Objects of type " + obj.getClass().getName() + " cannot be Percent encoded");
212 }
213
214 private int expectedDecodingBytes(final byte[] bytes) {
215 int byteCount = 0;
216 for (int i = 0; i < bytes.length;) {
217 final byte b = bytes[i];
218 i += b == ESCAPE_CHAR ? 3 : 1;
219 byteCount++;
220 }
221 return byteCount;
222 }
223
224 private int expectedEncodingBytes(final byte[] bytes) {
225 int byteCount = 0;
226 for (final byte b : bytes) {
227 byteCount += canEncode(b) ? 3 : 1;
228 }
229 return byteCount;
230 }
231
232 private boolean inAlwaysEncodeCharsRange(final byte c) {
233 return c >= alwaysEncodeCharsMin && c <= alwaysEncodeCharsMax;
234 }
235
236 /**
237 * Inserts a single character into a BitSet and maintains the min and max of the characters of the
238 * {@code BitSet alwaysEncodeChars} in order to avoid look-ups when a byte is out of this range.
239 *
240 * @param b The byte that is candidate for min and max limit.
241 */
242 private void insertAlwaysEncodeChar(final byte b) {
243 if (b < 0) {
244 throw new IllegalArgumentException("byte must be >= 0");
245 }
246 this.alwaysEncodeChars.set(b);
247 if (b < alwaysEncodeCharsMin) {
248 alwaysEncodeCharsMin = b;
249 }
250 if (b > alwaysEncodeCharsMax) {
251 alwaysEncodeCharsMax = b;
252 }
253 }
254
255 /**
256 * Inserts the byte array into a BitSet for faster lookup.
257 *
258 * @param alwaysEncodeCharsArray The byte array into a BitSet for faster lookup.
259 */
260 private void insertAlwaysEncodeChars(final byte[] alwaysEncodeCharsArray) {
261 if (alwaysEncodeCharsArray != null) {
262 for (final byte b : alwaysEncodeCharsArray) {
263 insertAlwaysEncodeChar(b);
264 }
265 }
266 insertAlwaysEncodeChar(ESCAPE_CHAR);
267 }
268
269 private boolean isAsciiChar(final byte c) {
270 return c >= 0;
271 }
272 }