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