2 * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others. All rights reserved.
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
8 package org.opendaylight.restconf.nb.rfc8040;
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;
14 import com.google.common.base.CharMatcher;
15 import com.google.common.collect.ImmutableList;
16 import com.google.common.collect.ImmutableList.Builder;
17 import java.text.ParseException;
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.nb.rfc8040.ApiPath.ApiIdentifier;
22 import org.opendaylight.restconf.nb.rfc8040.ApiPath.ListInstance;
23 import org.opendaylight.restconf.nb.rfc8040.ApiPath.Step;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
28 * Parser for a sequence of {@link ApiPath}'s {@link Step}s.
31 private static final Logger LOG = LoggerFactory.getLogger(ApiPathParser.class);
33 // FIXME: use these from YangNames
34 private static final CharMatcher IDENTIFIER_START =
35 CharMatcher.inRange('A', 'Z').or(CharMatcher.inRange('a', 'z').or(CharMatcher.is('_'))).precomputed();
36 private static final CharMatcher NOT_IDENTIFIER_PART =
37 IDENTIFIER_START.or(CharMatcher.inRange('0', '9')).or(CharMatcher.anyOf("-.")).negate().precomputed();
40 * A lenient interpretation: '//' is '/', i.e. there is no segment.
42 private static final class Lenient extends ApiPathParser {
44 int parseStep(final String str, final int offset, final int limit) throws ParseException {
45 return offset == limit ? limit : super.parseStep(str, offset, limit);
50 * A lenient interpretation: '//' is '/', i.e. there is no segment, but also log offenders
52 private static final class Logging extends ApiPathParser {
54 private interface LogMethod {
55 void logLeniency(String format, Object arg0, Object arg1);
58 private final LogMethod method;
60 Logging(final LogMethod method) {
61 this.method = requireNonNull(method);
65 int parseStep(final String str, final int offset, final int limit) throws ParseException {
66 if (offset == limit) {
67 method.logLeniency("Ignoring duplicate slash in '{}' at offset", str, offset);
70 return super.parseStep(str, offset, limit);
74 private static final Supplier<@NonNull ApiPathParser> URL_FACTORY;
77 // Select the correct parser implementation where consecutive slashes are concerned. We default to lenient
78 // interpretation and treat them as a single slash, but allow this to be overridden through a system property.
79 // FIXME: 3.0.0: make "reject" the default
80 final String prop = System.getProperty("org.opendaylight.restconf.url.consecutive-slashes", "allow");
81 final String treatment;
84 treatment = "are treated as a single slash";
85 URL_FACTORY = Lenient::new;
88 treatment = "are treated as a single slash and will be logged";
89 URL_FACTORY = () -> new Logging(LOG::debug);
92 treatment = "are treated as a single slash and will be warned about";
93 URL_FACTORY = () -> new Logging(LOG::warn);
96 treatment = "will be rejected";
97 URL_FACTORY = ApiPathParser::new;
100 LOG.warn("Unknown property value '{}', assuming 'reject'", prop);
101 treatment = "will be rejected";
102 URL_FACTORY = ApiPathParser::new;
105 LOG.info("Consecutive slashes in REST URLs {}", treatment);
108 private final Builder<Step> steps = ImmutableList.builder();
111 * State tracking for creating substrings:
113 * Usually we copy spans 'src', in which case subStart captures 'start' argument to String.substring(...).
114 * If we encounter a percent escape we need to interpret as part of the string, we start building the string in
115 * subBuilder -- in which case subStart is set to -1.
117 * Note that StringBuilder is lazily-instantiated, as we have no percents at all
119 private int subStart;
120 private StringBuilder subBuilder;
122 // Lazily-allocated when we need to decode UTF-8. Since we touch this only when we are not expecting
123 private Utf8Buffer buf;
125 // the offset of the character returned from last peekBasicLatin()
126 private int nextOffset;
128 private ApiPathParser() {
132 static @NonNull ApiPathParser newStrict() {
133 return new ApiPathParser();
136 static @NonNull ApiPathParser newUrl() {
137 return URL_FACTORY.get();
141 // steps : step ("/" step)*
142 final @NonNull ImmutableList<@NonNull Step> parseSteps(final String str) throws ParseException {
145 // First process while we are seeing a slash
147 final int slash = str.indexOf('/', idx);
149 final int next = parseStep(str, idx, slash);
150 verify(next == slash, "Unconsumed bytes: %s next %s limit", next, slash);
157 // Now process the tail of the string
158 final int length = str.length();
159 final int next = parseStep(str, idx, length);
160 verify(next == length, "Unconsumed trailing bytes: %s next %s limit", next, length);
162 return steps.build();
166 // step : identifier (":" identifier)? ("=" key-value ("," key-value)*)?
167 // Note: visible for subclasses
168 int parseStep(final String str, final int offset, final int limit) throws ParseException {
169 int idx = startIdentifier(str, offset, limit);
170 while (idx < limit) {
171 final char ch = peekBasicLatin(str, idx, limit);
173 return parseStep(endSub(str, idx), str, nextOffset, limit);
174 } else if (ch == '=') {
175 return parseStep(null, endSub(str, idx), str, nextOffset, limit);
177 idx = continueIdentifer(idx, ch);
180 steps.add(new ApiIdentifier(null, endSub(str, idx)));
184 // Starting at second identifier
185 private int parseStep(final @Nullable String module, final String str, final int offset, final int limit)
186 throws ParseException {
187 int idx = startIdentifier(str, offset, limit);
188 while (idx < limit) {
189 final char ch = peekBasicLatin(str, idx, limit);
191 return parseStep(module, endSub(str, idx), str, nextOffset, limit);
193 idx = continueIdentifer(idx, ch);
196 steps.add(new ApiIdentifier(module, endSub(str, idx)));
200 // Starting at first key-value
201 private int parseStep(final @Nullable String module, final @NonNull String identifier,
202 final String str, final int offset, final int limit) throws ParseException {
203 final var values = ImmutableList.<String>builder();
207 while (idx < limit) {
208 final char ch = str.charAt(idx);
210 values.add(endSub(str, idx));
212 } else if (ch != '%') {
216 // Save current string content and capture current index for reporting
217 final var sb = flushSub(str, idx);
218 final int errorOffset = idx;
222 buf = utf = new Utf8Buffer();
226 utf.appendByte(parsePercent(str, idx, limit));
228 } while (idx < limit && str.charAt(idx) == '%');
230 utf.flushTo(sb, errorOffset);
234 steps.add(new ListInstance(module, identifier, values.add(endSub(str, idx)).build()));
238 private int startIdentifier(final String str, final int offset, final int limit) throws ParseException {
239 if (offset == limit) {
240 throw new ParseException("Identifier may not be empty", offset);
244 final char ch = peekBasicLatin(str, offset, limit);
245 if (!IDENTIFIER_START.matches(ch)) {
246 throw new ParseException("Expecting [a-zA-Z_], not '" + ch + "'", offset);
252 private int continueIdentifer(final int offset, final char ch) throws ParseException {
253 if (NOT_IDENTIFIER_PART.matches(ch)) {
254 throw new ParseException("Expecting [a-zA-Z_.-], not '" + ch + "'", offset);
260 // Assert current character comes from the Basic Latin block, i.e. 00-7F.
261 // Callers are expected to pick up 'nextIdx' to resume parsing at the next character
262 private char peekBasicLatin(final String str, final int offset, final int limit) throws ParseException {
263 final char ch = str.charAt(offset);
265 final byte b = parsePercent(str, offset, limit);
267 throw new ParseException("Expecting %00-%7F, not " + str.substring(offset, limit), offset);
270 flushSub(str, offset);
271 nextOffset = offset + 3;
275 if (ch < 0 || ch > 127) {
276 throw new ParseException("Unexpected character '" + ch + "'", offset);
278 nextOffset = offset + 1;
282 private void startSub(final int offset) {
286 private void append(final char ch) {
287 // We are not reusing string, append the char, otherwise
288 if (subStart == -1) {
289 verifyNotNull(subBuilder).append(ch);
293 private @NonNull String endSub(final String str, final int end) {
294 return subStart != -1 ? str.substring(subStart, end) : verifyNotNull(subBuilder).toString();
297 private @NonNull StringBuilder flushSub(final String str, final int end) {
300 subBuilder = sb = new StringBuilder();
302 if (subStart != -1) {
304 sb.append(str, subStart, end);
310 private static byte parsePercent(final String str, final int offset, final int limit) throws ParseException {
311 if (limit - offset < 3) {
312 throw new ParseException("Incomplete escape '" + str.substring(offset, limit) + "'", offset);
314 return (byte) (parseHex(str, offset + 1) << 4 | parseHex(str, offset + 2));
317 // FIXME: Replace with HexFormat.fromHexDigit(str.charAt(offset)) when we have JDK17+
318 private static int parseHex(final String str, final int offset) throws ParseException {
319 final char ch = str.charAt(offset);
320 if (ch >= '0' && ch <= '9') {
325 if (ch >= 'a' && ch <= 'f') {
327 } else if (ch >= 'A' && ch <= 'F') {
330 throw new ParseException("Invalid escape character '" + ch + "'", offset);
333 return ch - zero + 10;