Move restconf.common.util
[netconf.git] / restconf / restconf-nb-rfc8040 / src / main / java / org / opendaylight / restconf / nb / rfc8040 / ApiPathParser.java
1 /*
2  * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6  * and is available at http://www.eclipse.org/legal/epl-v10.html
7  */
8 package org.opendaylight.restconf.nb.rfc8040;
9
10 import static com.google.common.base.Verify.verify;
11 import static com.google.common.base.Verify.verifyNotNull;
12 import static java.util.Objects.requireNonNull;
13
14 import com.google.common.collect.ImmutableList;
15 import com.google.common.collect.ImmutableList.Builder;
16 import java.text.ParseException;
17 import java.util.function.Supplier;
18 import org.eclipse.jdt.annotation.NonNull;
19 import org.eclipse.jdt.annotation.Nullable;
20 import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
21 import org.opendaylight.restconf.nb.rfc8040.ApiPath.ListInstance;
22 import org.opendaylight.restconf.nb.rfc8040.ApiPath.Step;
23 import org.opendaylight.yangtools.yang.common.YangNames;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26
27 /**
28  * Parser for a sequence of {@link ApiPath}'s {@link Step}s.
29  */
30 class ApiPathParser {
31     private static final Logger LOG = LoggerFactory.getLogger(ApiPathParser.class);
32
33     /**
34      * A lenient interpretation: '//' is '/', i.e. there is no segment.
35      */
36     private static final class Lenient extends ApiPathParser {
37         @Override
38         int parseStep(final String str, final int offset, final int limit) throws ParseException {
39             return offset == limit ? limit : super.parseStep(str, offset, limit);
40         }
41     }
42
43     /**
44      * A lenient interpretation: '//' is '/', i.e. there is no segment, but also log offenders
45      */
46     private static final class Logging extends ApiPathParser {
47         @FunctionalInterface
48         private interface LogMethod {
49             void logLeniency(String format, Object arg0, Object arg1);
50         }
51
52         private final LogMethod method;
53
54         Logging(final LogMethod method) {
55             this.method = requireNonNull(method);
56         }
57
58         @Override
59         int parseStep(final String str, final int offset, final int limit) throws ParseException {
60             if (offset == limit) {
61                 method.logLeniency("Ignoring duplicate slash in '{}' at offset", str, offset);
62                 return limit;
63             }
64             return super.parseStep(str, offset, limit);
65         }
66     }
67
68     private static final Supplier<@NonNull ApiPathParser> URL_FACTORY;
69
70     static {
71         // Select the correct parser implementation where consecutive slashes are concerned. We default to lenient
72         // interpretation and treat them as a single slash, but allow this to be overridden through a system property.
73         final String prop = System.getProperty("org.opendaylight.restconf.url.consecutive-slashes", "reject");
74         final String treatment;
75         switch (prop) {
76             case "allow":
77                 treatment = "are treated as a single slash";
78                 URL_FACTORY = Lenient::new;
79                 break;
80             case "debug":
81                 treatment = "are treated as a single slash and will be logged";
82                 URL_FACTORY = () -> new Logging(LOG::debug);
83                 break;
84             case "warn":
85                 treatment = "are treated as a single slash and will be warned about";
86                 URL_FACTORY = () -> new Logging(LOG::warn);
87                 break;
88             case "reject":
89                 treatment = "will be rejected";
90                 URL_FACTORY = ApiPathParser::new;
91                 break;
92             default:
93                 LOG.warn("Unknown property value '{}', assuming 'reject'", prop);
94                 treatment = "will be rejected";
95                 URL_FACTORY = ApiPathParser::new;
96         }
97
98         LOG.info("Consecutive slashes in REST URLs {}", treatment);
99     }
100
101     private final Builder<Step> steps = ImmutableList.builder();
102
103     /*
104      * State tracking for creating substrings:
105      *
106      * Usually we copy spans 'src', in which case subStart captures 'start' argument to String.substring(...).
107      * If we encounter a percent escape we need to interpret as part of the string, we start building the string in
108      * subBuilder -- in which case subStart is set to -1.
109      *
110      * Note that StringBuilder is lazily-instantiated, as we have no percents at all
111      */
112     private int subStart;
113     private StringBuilder subBuilder;
114
115     // Lazily-allocated when we need to decode UTF-8. Since we touch this only when we are not expecting
116     private Utf8Buffer buf;
117
118     // the offset of the character returned from last peekBasicLatin()
119     private int nextOffset;
120
121     private ApiPathParser() {
122         // Hidden on purpose
123     }
124
125     static @NonNull ApiPathParser newStrict() {
126         return new ApiPathParser();
127     }
128
129     static @NonNull ApiPathParser newUrl() {
130         return URL_FACTORY.get();
131     }
132
133     // Grammar:
134     //   steps : step ("/" step)*
135     final @NonNull ImmutableList<@NonNull Step> parseSteps(final String str) throws ParseException {
136         int idx = 0;
137
138         // First process while we are seeing a slash
139         while (true) {
140             final int slash = str.indexOf('/', idx);
141             if (slash != -1) {
142                 final int next = parseStep(str, idx, slash);
143                 verify(next == slash, "Unconsumed bytes: %s next %s limit", next, slash);
144                 idx = next + 1;
145             } else {
146                 break;
147             }
148         }
149
150         // Now process the tail of the string
151         final int length = str.length();
152         final int next = parseStep(str, idx, length);
153         verify(next == length, "Unconsumed trailing bytes: %s next %s limit", next, length);
154
155         return steps.build();
156     }
157
158     // Grammar:
159     //   step : identifier (":" identifier)? ("=" key-value ("," key-value)*)?
160     // Note: visible for subclasses
161     int parseStep(final String str, final int offset, final int limit) throws ParseException {
162         int idx = startIdentifier(str, offset, limit);
163         while (idx < limit) {
164             final char ch = peekBasicLatin(str, idx, limit);
165             if (ch == ':') {
166                 return parseStep(endSub(str, idx), str, nextOffset, limit);
167             } else if (ch == '=') {
168                 return parseStep(null, endSub(str, idx), str, nextOffset, limit);
169             }
170             idx = continueIdentifer(idx, ch);
171         }
172
173         steps.add(new ApiIdentifier(null, endSub(str, idx)));
174         return idx;
175     }
176
177     // Starting at second identifier
178     private int parseStep(final @Nullable String module, final String str, final int offset, final int limit)
179             throws ParseException {
180         int idx = startIdentifier(str, offset, limit);
181         while (idx < limit) {
182             final char ch = peekBasicLatin(str, idx, limit);
183             if (ch == '=') {
184                 return parseStep(module, endSub(str, idx), str, nextOffset, limit);
185             }
186             idx = continueIdentifer(idx, ch);
187         }
188
189         steps.add(new ApiIdentifier(module, endSub(str, idx)));
190         return idx;
191     }
192
193     // Starting at first key-value
194     private int parseStep(final @Nullable String module, final @NonNull String identifier,
195             final String str, final int offset, final int limit) throws ParseException {
196         final var values = ImmutableList.<String>builder();
197
198         startSub(offset);
199         int idx = offset;
200         while (idx < limit) {
201             final char ch = str.charAt(idx);
202             if (ch == ',') {
203                 values.add(endSub(str, idx));
204                 startSub(++idx);
205             } else if (ch != '%') {
206                 append(ch);
207                 idx++;
208             } else {
209                 // Save current string content and capture current index for reporting
210                 final var sb = flushSub(str, idx);
211                 final int errorOffset = idx;
212
213                 var utf = buf;
214                 if (utf == null) {
215                     buf = utf = new Utf8Buffer();
216                 }
217
218                 do {
219                     utf.appendByte(parsePercent(str, idx, limit));
220                     idx += 3;
221                 } while (idx < limit && str.charAt(idx) == '%');
222
223                 utf.flushTo(sb, errorOffset);
224             }
225         }
226
227         steps.add(new ListInstance(module, identifier, values.add(endSub(str, idx)).build()));
228         return idx;
229     }
230
231     private int startIdentifier(final String str, final int offset, final int limit) throws ParseException {
232         if (offset == limit) {
233             throw new ParseException("Identifier may not be empty", offset);
234         }
235
236         startSub(offset);
237         final char ch = peekBasicLatin(str, offset, limit);
238         if (!YangNames.IDENTIFIER_START.matches(ch)) {
239             throw new ParseException("Expecting [a-zA-Z_], not '" + ch + "'", offset);
240         }
241         append(ch);
242         return nextOffset;
243     }
244
245     private int continueIdentifer(final int offset, final char ch) throws ParseException {
246         if (YangNames.NOT_IDENTIFIER_PART.matches(ch)) {
247             throw new ParseException("Expecting [a-zA-Z_.-], not '" + ch + "'", offset);
248         }
249         append(ch);
250         return nextOffset;
251     }
252
253     // Assert current character comes from the Basic Latin block, i.e. 00-7F.
254     // Callers are expected to pick up 'nextIdx' to resume parsing at the next character
255     private char peekBasicLatin(final String str, final int offset, final int limit) throws ParseException {
256         final char ch = str.charAt(offset);
257         if (ch == '%') {
258             final byte b = parsePercent(str, offset, limit);
259             if (b < 0) {
260                 throw new ParseException("Expecting %00-%7F, not " + str.substring(offset, limit), offset);
261             }
262
263             flushSub(str, offset);
264             nextOffset = offset + 3;
265             return (char) b;
266         }
267
268         if (ch < 0 || ch > 127) {
269             throw new ParseException("Unexpected character '" + ch + "'", offset);
270         }
271         nextOffset = offset + 1;
272         return ch;
273     }
274
275     private void startSub(final int offset) {
276         subStart = offset;
277     }
278
279     private void append(final char ch) {
280         // We are not reusing string, append the char, otherwise
281         if (subStart == -1) {
282             verifyNotNull(subBuilder).append(ch);
283         }
284     }
285
286     private @NonNull String endSub(final String str, final int end) {
287         return subStart != -1 ? str.substring(subStart, end) : verifyNotNull(subBuilder).toString();
288     }
289
290     private @NonNull StringBuilder flushSub(final String str, final int end) {
291         var sb = subBuilder;
292         if (sb == null) {
293             subBuilder = sb = new StringBuilder();
294         }
295         if (subStart != -1) {
296             sb.setLength(0);
297             sb.append(str, subStart, end);
298             subStart = -1;
299         }
300         return sb;
301     }
302
303     private static byte parsePercent(final String str, final int offset, final int limit) throws ParseException {
304         if (limit - offset < 3) {
305             throw new ParseException("Incomplete escape '" + str.substring(offset, limit) + "'", offset);
306         }
307         return (byte) (parseHex(str, offset + 1) << 4 | parseHex(str, offset + 2));
308     }
309
310     // FIXME: Replace with HexFormat.fromHexDigit(str.charAt(offset)) when we have JDK17+
311     private static int parseHex(final String str, final int offset) throws ParseException {
312         final char ch = str.charAt(offset);
313         if (ch >= '0' && ch <= '9') {
314             return ch - '0';
315         }
316
317         final int zero;
318         if (ch >= 'a' && ch <= 'f') {
319             zero = 'a';
320         } else if (ch >= 'A' && ch <= 'F') {
321             zero = 'A';
322         } else {
323             throw new ParseException("Invalid escape character '" + ch + "'", offset);
324         }
325
326         return ch - zero + 10;
327     }
328 }