001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      https://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.apache.commons.codec.net;
019
020import java.nio.ByteBuffer;
021import java.util.BitSet;
022
023import org.apache.commons.codec.BinaryDecoder;
024import org.apache.commons.codec.BinaryEncoder;
025import org.apache.commons.codec.DecoderException;
026import org.apache.commons.codec.EncoderException;
027
028/**
029 * Implements the Percent-Encoding scheme, as described in HTTP 1.1 specification. For extensibility, an array of
030 * special US-ASCII characters can be specified in order to perform proper URI encoding for the different parts
031 * of the URI.
032 * <p>
033 * This class is immutable. It is also thread-safe besides using BitSet which is not thread-safe, but its public
034 * interface only call the access
035 * </p>
036 *
037 * @see <a href="https://tools.ietf.org/html/rfc3986#section-2.1">Percent-Encoding</a>
038 * @since 1.12
039 */
040public class PercentCodec implements BinaryEncoder, BinaryDecoder {
041
042    /**
043     * The escape character used by the Percent-Encoding in order to introduce an encoded character.
044     */
045    private static final byte ESCAPE_CHAR = '%';
046
047    /**
048     * The plus character used to encode spaces when plusForSpace is true.
049     */
050    private static final byte PLUS_CHAR = '+';
051
052    /**
053     * The bit set used to store the character that should be always encoded.
054     */
055    private final BitSet alwaysEncodeChars = new BitSet();
056
057    /**
058     * The flag defining if the space character should be encoded as '+'.
059     */
060    private final boolean plusForSpace;
061
062    /**
063     * The minimum and maximum code of the bytes that is inserted in the bit set, used to prevent look-ups
064     */
065    private int alwaysEncodeCharsMin = Integer.MAX_VALUE, alwaysEncodeCharsMax = Integer.MIN_VALUE;
066
067    /**
068     * Constructs a Percent coded that will encode all the non US-ASCII characters using the Percent-Encoding
069     * while it will not encode all the US-ASCII characters, except for character '%' that is used as escape
070     * character for Percent-Encoding.
071     */
072    public PercentCodec() {
073        this.plusForSpace = false;
074        insertAlwaysEncodeChar(ESCAPE_CHAR);
075    }
076
077    /**
078     * Constructs a Percent codec by specifying the characters that belong to US-ASCII that should
079     * always be encoded. The rest US-ASCII characters will not be encoded, except for character '%' that
080     * is used as escape character for Percent-Encoding.
081     *
082     * @param alwaysEncodeChars The unsafe characters that should always be encoded.
083     * @param plusForSpace      The flag defining if the space character should be encoded as '+'.
084     */
085    public PercentCodec(final byte[] alwaysEncodeChars, final boolean plusForSpace) {
086        this.plusForSpace = plusForSpace;
087        insertAlwaysEncodeChars(alwaysEncodeChars);
088        if (plusForSpace) {
089            insertAlwaysEncodeChar(PLUS_CHAR);
090        }
091    }
092
093    private boolean canEncode(final byte c) {
094        return !isAsciiChar(c) || inAlwaysEncodeCharsRange(c) && alwaysEncodeChars.get(c);
095    }
096
097    private boolean containsSpace(final byte[] bytes) {
098        for (final byte b : bytes) {
099            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    @Override
111    public byte[] decode(final byte[] bytes) throws DecoderException {
112        if (bytes == null) {
113            return null;
114        }
115        final ByteBuffer buffer = ByteBuffer.allocate(expectedDecodingBytes(bytes));
116        for (int i = 0; i < bytes.length; i++) {
117            final byte b = bytes[i];
118            if (b == ESCAPE_CHAR) {
119                try {
120                    final int u = Utils.digit16(bytes[++i]);
121                    final int l = Utils.digit16(bytes[++i]);
122                    buffer.put((byte) ((u << 4) + l));
123                } catch (final ArrayIndexOutOfBoundsException e) {
124                    throw new DecoderException("Invalid percent decoding: ", e);
125                }
126            } else if (plusForSpace && b == '+') {
127                buffer.put((byte) ' ');
128            } else {
129                buffer.put(b);
130            }
131        }
132        return buffer.array();
133    }
134
135    /**
136     * Decodes a byte[] Object, whose bytes are encoded with Percent-Encoding.
137     *
138     * @param obj The object to decode.
139     * @return The decoding result byte[] as Object.
140     * @throws DecoderException Thrown if the object is not a byte array.
141     */
142    @Override
143    public Object decode(final Object obj) throws DecoderException {
144        if (obj == null) {
145            return null;
146        }
147        if (obj instanceof byte[]) {
148            return decode((byte[]) obj);
149        }
150        throw new DecoderException("Objects of type " + obj.getClass().getName() + " cannot be Percent decoded");
151    }
152
153    private byte[] doEncode(final byte[] bytes, final int expectedLength, final boolean willEncode) {
154        final ByteBuffer buffer = ByteBuffer.allocate(expectedLength);
155        for (final byte b : bytes) {
156            if (willEncode && canEncode(b)) {
157                byte bb = b;
158                if (bb < 0) {
159                    bb = (byte) (256 + bb);
160                }
161                final char hex1 = Utils.hexChar(bb >> 4);
162                final char hex2 = Utils.hexChar(bb);
163                buffer.put(ESCAPE_CHAR);
164                buffer.put((byte) hex1);
165                buffer.put((byte) hex2);
166            } else if (plusForSpace && b == ' ') {
167                buffer.put((byte) '+');
168            } else {
169                buffer.put(b);
170            }
171        }
172        return buffer.array();
173    }
174
175    /**
176     * Percent-Encoding based on RFC 3986. The non US-ASCII characters are encoded, as well as the
177     * US-ASCII characters that are configured to be always encoded.
178     */
179    @Override
180    public byte[] encode(final byte[] bytes) throws EncoderException {
181        if (bytes == null) {
182            return null;
183        }
184        final int expectedEncodingBytes = expectedEncodingBytes(bytes);
185        final boolean willEncode = expectedEncodingBytes != bytes.length;
186        if (willEncode || plusForSpace && containsSpace(bytes)) {
187            return doEncode(bytes, expectedEncodingBytes, willEncode);
188        }
189        return bytes;
190    }
191
192    /**
193     * Encodes an object into using the Percent-Encoding. Only byte[] objects are accepted.
194     *
195     * @param obj The object to encode.
196     * @return The encoding result byte[] as Object.
197     * @throws EncoderException Thrown if the object is not a byte array.
198     */
199    @Override
200    public Object encode(final Object obj) throws EncoderException {
201        if (obj == null) {
202            return null;
203        }
204        if (obj instanceof byte[]) {
205            return encode((byte[]) obj);
206        }
207        throw new EncoderException("Objects of type " + obj.getClass().getName() + " cannot be Percent encoded");
208    }
209
210    private int expectedDecodingBytes(final byte[] bytes) {
211        int byteCount = 0;
212        for (int i = 0; i < bytes.length;) {
213            final byte b = bytes[i];
214            i += b == ESCAPE_CHAR ? 3 : 1;
215            byteCount++;
216        }
217        return byteCount;
218    }
219
220    private int expectedEncodingBytes(final byte[] bytes) {
221        int byteCount = 0;
222        for (final byte b : bytes) {
223            byteCount += canEncode(b) ? 3 : 1;
224        }
225        return byteCount;
226    }
227
228    private boolean inAlwaysEncodeCharsRange(final byte c) {
229        return c >= alwaysEncodeCharsMin && c <= alwaysEncodeCharsMax;
230    }
231
232    /**
233     * Inserts a single character into a BitSet and maintains the min and max of the characters of the
234     * {@code BitSet alwaysEncodeChars} in order to avoid look-ups when a byte is out of this range.
235     *
236     * @param b The byte that is candidate for min and max limit.
237     */
238    private void insertAlwaysEncodeChar(final byte b) {
239        if (b < 0) {
240            throw new IllegalArgumentException("byte must be >= 0");
241        }
242        this.alwaysEncodeChars.set(b);
243        if (b < alwaysEncodeCharsMin) {
244            alwaysEncodeCharsMin = b;
245        }
246        if (b > alwaysEncodeCharsMax) {
247            alwaysEncodeCharsMax = b;
248        }
249    }
250
251    /**
252     * Inserts the byte array into a BitSet for faster lookup.
253     *
254     * @param alwaysEncodeCharsArray The byte array into a BitSet for faster lookup.
255     */
256    private void insertAlwaysEncodeChars(final byte[] alwaysEncodeCharsArray) {
257        if (alwaysEncodeCharsArray != null) {
258            for (final byte b : alwaysEncodeCharsArray) {
259                insertAlwaysEncodeChar(b);
260            }
261        }
262        insertAlwaysEncodeChar(ESCAPE_CHAR);
263    }
264
265    private boolean isAsciiChar(final byte c) {
266        return c >= 0;
267    }
268}