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 * http://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 */ 017package org.apache.juneau.rest.server.mcp; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.nio.*; 023import java.nio.charset.*; 024import java.util.*; 025import java.util.regex.*; 026 027/** 028 * Hand-rolled, dependency-free compiler and reverse matcher for the narrow, reliably-invertible subset of 029 * <a class="doclink" href="https://www.rfc-editor.org/rfc/rfc6570">RFC 6570</a> URI templates used by MCP 030 * resource-template reads. 031 * 032 * <p> 033 * A template is {@link #compile(String) compiled} once into an immutable matcher that exposes: 034 * <ul> 035 * <li>{@link #isReverseMatchable()} - whether the template is one of the settled reversible forms (simple, 036 * reserved, fragment, path, label, or query scalar expressions, with no explode/prefix modifier and no 037 * ambiguous adjacency to another expression); 038 * <li>{@link #literalOctetCount()} - the number of normalized literal octets outside <c>{...}</c> expressions, 039 * for registry specificity ranking; 040 * <li>{@link #variableCount()} and {@link #variableNames()} - the declared template variables, in declaration 041 * order; and 042 * <li>{@link #match(String)} - reverse-matches a concrete URI against a reverse-matchable template, returning 043 * an immutable, insertion-ordered, percent-decoded variable map, or <jk>null</jk> if the URI does not match. 044 * </ul> 045 * 046 * <h5 class='section'>Reverse-matchable forms:</h5> 047 * <p> 048 * Non-explode, non-prefix simple scalar <c>{var}</c>, reserved scalar <c>{+var}</c>, fragment scalar 049 * <c>{#var}</c>, path segments <c>{/var}</c> / <c>{/x,y}</c>, label segments <c>{.var}</c> / <c>{.x,y}</c>, query 050 * start <c>{?x}</c> / <c>{?x,y}</c>, and query continuation <c>{&x}</c> / <c>{&x,y}</c>. All other legal 051 * RFC 6570 forms - exploded (<c>*</c>), prefixed (<c>:n</c>), matrix (<c>;</c>), multi-variable simple/reserved/ 052 * fragment expressions, and expressions with ambiguous adjacency to a neighboring expression - parse 053 * successfully (so they remain listable and completable) but are never selected for a template-backed read; 054 * {@link #match(String)} always returns <jk>null</jk> for them. 055 * 056 * <h5 class='section'>Percent-encoding normalization:</h5> 057 * <p> 058 * Matching and literal-specificity counting follow RFC 3986 percent-normalization implemented fresh by this 059 * class: valid <c>%HH</c> triplets are hex-uppercased; triplets that decode to an unreserved octet 060 * (<c>ALPHA</c> / <c>DIGIT</c> / <c>-</c> / <c>.</c> / <c>_</c> / <c>~</c>) normalize to their literal 061 * character for comparison; triplets that decode to a reserved octet stay encoded while match boundaries are 062 * computed, so an encoded <js>"%2F"</js> is data inside a <c>{var}</c> segment rather than a path separator; 063 * captured values are then UTF-8 percent-decoded before being placed in the result map; and <c>+</c> is always 064 * a literal plus (this class never applies {@code application/x-www-form-urlencoded} plus-as-space semantics). 065 * A concrete URI with a malformed percent escape, or a capture whose decoded bytes are not valid UTF-8, never 066 * matches. This class deliberately does <b>not</b> reuse 067 * {@code org.apache.juneau.commons.utils.StringUtils} <c>urlEncode</c>/<c>urlDecode</c>/<c>fixUrl</c>/ 068 * <c>urlEncodePath</c> or the SVL <c>EncodingFunctions</c>: those implement form-urlencoded semantics 069 * (<c>+</c> as space) and lack hex-case normalization, reserved-octet preservation, and malformed-escape 070 * rejection, all of which contradict the rules above. 071 * 072 * <h5 class='section'>Ambiguous-capture resolution:</h5> 073 * <p> 074 * A reversible template compiles to an alternating sequence of literal and capture pieces; because an 075 * expression with ambiguous adjacency to another expression is rejected as non-reverse-matchable (see above), 076 * a capture piece in a reverse-matchable template is always immediately followed by either a literal piece or 077 * the end of the template, never by another capture. When a reserved/fragment (unbounded) or path/label/simple 078 * (bounded-by-<c>/</c>) capture is immediately followed by a literal, {@link #match(String)} resolves the 079 * capture's length via a single longest-to-shortest search for the position at which that immediately-following 080 * literal text occurs, using {@link String#lastIndexOf(String, int)}. This deterministically implements "the 081 * longest capture that still permits the (immediately following) literal to match" without whole-suffix 082 * backtracking across multiple capture/literal boundaries, which keeps matching a bounded, linear-in-input scan 083 * per capture with no regex-style catastrophic backtracking risk, at the cost of not re-trying an earlier 084 * capture's length choice if a later, unrelated piece of the template fails to match. That scope narrowing is 085 * intentional and consistent with this matcher covering reliable inversion of a narrow subset, not a complete 086 * RFC 6570 engine. 087 * 088 * <h5 class='section'>Scope:</h5> 089 * <p> 090 * This matcher is intentionally MCP-local. It is not registered as a general-purpose RFC 3986/6570 utility; 091 * if a second real consumer outside MCP resource templates needs equivalent percent-normalization or 092 * URI-template parsing, that would justify extracting a shared helper into {@code juneau-commons} at that time, 093 * not before. 094 */ 095@SuppressWarnings({ 096 "java:S1168", // null means "template does not match"; an empty map would mean "matched with zero variables" - collapsing the two would turn non-matches into false matches. 097 "java:S3776" // Intentional: hand-rolled RFC 6570 reverse matcher; control-flow verified by McpUriTemplateMatcher_Test, refactor would risk matching correctness. 098}) 099public final class McpUriTemplateMatcher { 100 101 //----------------------------------------------------------------------------------------------------------------- 102 // Compilation entry point 103 //----------------------------------------------------------------------------------------------------------------- 104 105 /** 106 * Parses and compiles a single RFC 6570 URI template. 107 * 108 * <p> 109 * Registration syntax is validated independently of reverse-matchability: a syntactically legal template 110 * outside the reversible subset compiles successfully with {@link #isReverseMatchable()} returning 111 * <jk>false</jk>, so it remains listable and completable even though {@link #match(String)} always returns 112 * <jk>null</jk> for it. 113 * 114 * @param uriTemplate The URI template to compile. Must not be <jk>null</jk> or blank. 115 * @return A new compiled matcher. 116 * @throws IllegalArgumentException If the template is <jk>null</jk>/blank, has malformed braces, has a 117 * malformed percent escape, has invalid operator/varspec grammar, has a literal segment containing an 118 * invalid UTF-8 percent-escape sequence, or declares the same variable name more than once. 119 */ 120 public static McpUriTemplateMatcher compile(String uriTemplate) { 121 if (ib(uriTemplate)) 122 throw iaex("Malformed URI template ''%s'': template must not be null or blank", uriTemplate); 123 124 var tokens = tokenize(uriTemplate); 125 var declaredVars = new LinkedHashSet<String>(); 126 var pieces = new ArrayList<Piece>(); 127 var matchable = true; 128 var literalOctetCount = 0; 129 RawToken previous = null; 130 131 for (var token : tokens) { 132 if (token instanceof RawLiteral lit) { 133 var normalized = normalizeLiteralStrict(lit.text(), uriTemplate); 134 literalOctetCount += countOctets(normalized); 135 pieces.add(new LiteralPiece(normalized)); 136 } else if (token instanceof RawExpr expr) { 137 if (previous instanceof RawExpr) 138 matchable = false; 139 var parsed = parseExpr(expr.body(), uriTemplate, expr.index()); 140 for (var v : parsed.vars()) { 141 if (! declaredVars.add(v.name())) 142 throw iaex("Malformed URI template ''%s'': duplicate variable ''%s''", uriTemplate, v.name()); 143 } 144 if (isExprReverseMatchable(parsed)) 145 pieces.addAll(desugar(parsed)); 146 else 147 matchable = false; 148 } 149 previous = token; 150 } 151 152 return new McpUriTemplateMatcher(uriTemplate, matchable, literalOctetCount, List.copyOf(declaredVars), 153 List.copyOf(pieces)); 154 } 155 156 //----------------------------------------------------------------------------------------------------------------- 157 // Instance state and accessors 158 //----------------------------------------------------------------------------------------------------------------- 159 160 private final String template; 161 private final boolean reverseMatchable; 162 private final int literalOctetCount; 163 private final List<String> variableNames; 164 private final List<Piece> pieces; 165 166 private McpUriTemplateMatcher(String template, boolean reverseMatchable, int literalOctetCount, 167 List<String> variableNames, List<Piece> pieces) { 168 this.template = template; 169 this.reverseMatchable = reverseMatchable; 170 this.literalOctetCount = literalOctetCount; 171 this.variableNames = variableNames; 172 this.pieces = pieces; 173 } 174 175 /** 176 * The original source template this matcher was compiled from. 177 * 178 * @return The original template string. Never <jk>null</jk>. 179 */ 180 public String template() { 181 return template; 182 } 183 184 /** 185 * Whether this template is one of C4's reversible forms and therefore eligible for template-backed reads. 186 * 187 * @return <jk>true</jk> if {@link #match(String)} can ever return a non-<jk>null</jk> result. 188 */ 189 public boolean isReverseMatchable() { 190 return reverseMatchable; 191 } 192 193 /** 194 * The count of normalized literal URI octets outside <c>{...}</c> expressions. 195 * 196 * <p> 197 * A normalized percent triplet counts as one literal octet, not three source characters. This is exposed 198 * purely as ranking metadata; this class applies no ranking itself. Registry code performing final ranking 199 * must not invent additional tie-breakers (operator type, capture width, or value length) beyond this count, 200 * {@link #variableCount()}, and registration order. 201 * 202 * @return The literal octet count. 203 */ 204 public int literalOctetCount() { 205 return literalOctetCount; 206 } 207 208 /** 209 * The number of distinct variables declared by this template. 210 * 211 * @return The declared variable count. 212 */ 213 public int variableCount() { 214 return variableNames.size(); 215 } 216 217 /** 218 * The variables declared by this template, in declaration order. 219 * 220 * <p> 221 * Populated regardless of {@link #isReverseMatchable()}, so variables of a valid-but-not-reverse-matchable 222 * template remain available for exact-template-string completion lookups. 223 * 224 * @return An immutable, insertion-ordered list of declared variable names. Never <jk>null</jk>. 225 */ 226 public List<String> variableNames() { 227 return variableNames; 228 } 229 230 //----------------------------------------------------------------------------------------------------------------- 231 // Matching 232 //----------------------------------------------------------------------------------------------------------------- 233 234 /** 235 * Reverse-matches a concrete URI against this template. 236 * 237 * @param uri The concrete URI to match. Can be <jk>null</jk>, in which case this method returns <jk>null</jk>. 238 * @return An immutable, insertion-ordered, UTF-8-decoded map of variable name to decoded value, in 239 * declaration order, or <jk>null</jk> if this template is not {@link #isReverseMatchable() reverse-matchable}, 240 * the URI does not match, the URI contains a malformed percent escape, or a captured value's decoded bytes 241 * are not valid UTF-8. 242 */ 243 public Map<String,String> match(String uri) { 244 if (! reverseMatchable || uri == null) 245 return null; 246 var normalized = normalizeForMatch(uri); 247 if (normalized == null) 248 return null; 249 return matchPieces(normalized); 250 } 251 252 /** 253 * Left-to-right single-pass match against the compiled, alternating literal/capture piece list. 254 * 255 * <p> 256 * See the class Javadoc "Ambiguous-capture resolution" section for the boundary-resolution algorithm and its 257 * complexity guarantee. 258 */ 259 @SuppressWarnings({ 260 "java:S127", // The i++ skip of the paired literal piece after consuming a capture is intentional and documented by the class-level "Ambiguous-capture resolution" section. 261 "java:S135" // Single continue plus the loop-counter skip above are the simplest expression of this piece-by-piece scan; splitting it out would risk matching correctness. 262 }) 263 private Map<String,String> matchPieces(String normalized) { 264 var captures = new LinkedHashMap<String,String>(); 265 var pos = 0; 266 for (var i = 0; i < pieces.size(); i++) { 267 var piece = pieces.get(i); 268 if (piece instanceof LiteralPiece lit) { 269 if (! normalized.startsWith(lit.text(), pos)) 270 return null; 271 pos += lit.text().length(); 272 continue; 273 } 274 var capture = (CapturePiece) piece; 275 var maxEnd = scanMaxEnd(normalized, pos, capture.stopChars()); 276 // Invariant established at compile time: a capture piece is always followed by either a literal 277 // piece or the end of the template, never by another capture (see class Javadoc). 278 var nextLiteral = (i + 1 < pieces.size() && pieces.get(i + 1) instanceof LiteralPiece lp) ? lp : null; 279 String rawValue; 280 if (nextLiteral == null) { 281 rawValue = normalized.substring(pos, maxEnd); 282 pos = maxEnd; 283 } else { 284 var literalText = nextLiteral.text(); 285 var searchFrom = Math.min(maxEnd, normalized.length() - literalText.length()); 286 var foundAt = searchFrom < pos ? -1 : normalized.lastIndexOf(literalText, searchFrom); 287 if (foundAt < pos) 288 return null; 289 rawValue = normalized.substring(pos, foundAt); 290 pos = foundAt + literalText.length(); 291 i++; 292 } 293 var decoded = decodeCapture(rawValue); 294 if (decoded == null) 295 return null; 296 captures.put(capture.name(), decoded); 297 } 298 if (pos != normalized.length()) 299 return null; 300 return Collections.unmodifiableMap(captures); 301 } 302 303 /** 304 * Scans forward from {@code pos} while the current character is not one of {@code stopChars}, returning the 305 * bound at which a capture governed by that stop set must end. An empty stop set (reserved/fragment captures) 306 * scans unconditionally to the end of the string. 307 * 308 * <p> 309 * Because {@code stopChars} only ever contains reserved delimiter characters (<c>/</c>, <c>.</c>, <c>&</c>, 310 * <c>#</c>) - none of which are <c>%</c> or a hex digit - a normalized <c>%XX</c> triplet can never be 311 * mistaken for one of these characters, so a plain character scan over the already-normalized string 312 * correctly treats an encoded delimiter as data rather than a boundary. 313 */ 314 private static int scanMaxEnd(String normalized, int pos, Set<Character> stopChars) { 315 if (stopChars.isEmpty()) 316 return normalized.length(); 317 var i = pos; 318 while (i < normalized.length() && ! stopChars.contains(normalized.charAt(i))) 319 i++; 320 return i; 321 } 322 323 //----------------------------------------------------------------------------------------------------------------- 324 // Tokenizing: split the raw template into literal and expression segments 325 //----------------------------------------------------------------------------------------------------------------- 326 327 private sealed interface RawToken permits RawLiteral, RawExpr {} 328 329 private record RawLiteral(String text) implements RawToken {} 330 331 private record RawExpr(String body, int index) implements RawToken {} 332 333 private static List<RawToken> tokenize(String template) { 334 var out = new ArrayList<RawToken>(); 335 var n = template.length(); 336 var i = 0; 337 var lit = new StringBuilder(); 338 while (i < n) { 339 var c = template.charAt(i); 340 if (c == '{') { 341 if (! lit.isEmpty()) { 342 out.add(new RawLiteral(lit.toString())); 343 lit.setLength(0); 344 } 345 var close = template.indexOf('}', i + 1); 346 if (close < 0) 347 throw iaex("Malformed URI template ''%s'': unterminated '{' at index %s", template, i); 348 var body = template.substring(i + 1, close); 349 if (body.indexOf('{') >= 0) 350 throw iaex("Malformed URI template ''%s'': nested '{' inside expression at index %s", template, i); 351 if (body.isEmpty()) 352 throw iaex("Malformed URI template ''%s'': empty expression '{}' at index %s", template, i); 353 out.add(new RawExpr(body, i)); 354 i = close + 1; 355 } else if (c == '}') { 356 throw iaex("Malformed URI template ''%s'': unmatched '}' at index %s", template, i); 357 } else { 358 lit.append(c); 359 i++; 360 } 361 } 362 if (! lit.isEmpty()) 363 out.add(new RawLiteral(lit.toString())); 364 return out; 365 } 366 367 //----------------------------------------------------------------------------------------------------------------- 368 // Expression grammar: operator + comma-separated varspec list 369 //----------------------------------------------------------------------------------------------------------------- 370 371 private enum Modifier { NONE, PREFIX, EXPLODE } 372 373 private record VarSpec(String name, Modifier modifier) {} 374 375 private record ParsedExpr(char operator, List<VarSpec> vars) {} 376 377 private static final Set<Character> OPERATORS = Set.of('+', '#', '.', '/', ';', '?', '&'); 378 private static final Set<Character> RESERVED_FOR_FUTURE_OPERATORS = Set.of('!', '=', ',', '|', '@'); 379 380 @SuppressWarnings({ 381 "java:S5998" // Bounded by RFC 6570 varname grammar (dot-separated [A-Za-z0-9_]+ segments); real inputs are short template strings, not attacker-controlled bulk text. 382 }) 383 private static final Pattern VARNAME = Pattern.compile("\\w+(?:\\.\\w+)*"); 384 385 private static ParsedExpr parseExpr(String body, String template, int index) { 386 var first = body.charAt(0); 387 char operator; 388 String rest; 389 if (OPERATORS.contains(first)) { 390 operator = first; 391 rest = body.substring(1); 392 } else if (RESERVED_FOR_FUTURE_OPERATORS.contains(first)) { 393 throw iaex("Malformed URI template ''%s'': unsupported operator ''%s'' in expression at index %s", 394 template, first, index); 395 } else { 396 operator = '\0'; 397 rest = body; 398 } 399 if (rest.isEmpty()) 400 throw iaex("Malformed URI template ''%s'': empty variable list in expression at index %s", template, index); 401 var vars = Arrays.stream(rest.split(",", -1)).map(part -> parseVarSpec(part, template, index)).toList(); 402 return new ParsedExpr(operator, vars); 403 } 404 405 private static VarSpec parseVarSpec(String spec, String template, int index) { 406 if (spec.isEmpty()) 407 throw iaex("Malformed URI template ''%s'': empty variable name in expression at index %s", template, index); 408 if (spec.endsWith("*")) { 409 var name = spec.substring(0, spec.length() - 1); 410 validateVarName(name, template, index); 411 return new VarSpec(name, Modifier.EXPLODE); 412 } 413 var colon = spec.indexOf(':'); 414 if (colon >= 0) { 415 var name = spec.substring(0, colon); 416 var digits = spec.substring(colon + 1); 417 validateVarName(name, template, index); 418 if (digits.isEmpty() || digits.length() > 4 || ! digits.chars().allMatch(Character::isDigit)) 419 throw iaex("Malformed URI template ''%s'': invalid prefix-length modifier ''%s'' in expression at index %s", 420 template, spec, index); 421 return new VarSpec(name, Modifier.PREFIX); 422 } 423 validateVarName(spec, template, index); 424 return new VarSpec(spec, Modifier.NONE); 425 } 426 427 private static void validateVarName(String name, String template, int index) { 428 if (! VARNAME.matcher(name).matches()) 429 throw iaex("Malformed URI template ''%s'': invalid variable name ''%s'' in expression at index %s", 430 template, name, index); 431 } 432 433 //----------------------------------------------------------------------------------------------------------------- 434 // Matchability classification and desugaring into literal/capture pieces 435 //----------------------------------------------------------------------------------------------------------------- 436 437 private sealed interface Piece permits LiteralPiece, CapturePiece {} 438 439 private record LiteralPiece(String text) implements Piece {} 440 441 private record CapturePiece(String name, Set<Character> stopChars) implements Piece {} 442 443 private static final Set<Character> STOP_SLASH = Set.of('/'); 444 private static final Set<Character> STOP_SLASH_DOT = Set.of('/', '.'); 445 private static final Set<Character> STOP_QUERY = Set.of('&', '#'); 446 private static final Set<Character> STOP_NONE = Set.of(); 447 448 /** 449 * Determines whether a single parsed expression is one of the settled reversible forms, independent of its 450 * neighbors (adjacency ambiguity is a whole-template concern handled by the caller). 451 */ 452 private static boolean isExprReverseMatchable(ParsedExpr expr) { 453 if (expr.vars().stream().anyMatch(v -> v.modifier() != Modifier.NONE)) 454 return false; 455 return switch (expr.operator()) { 456 case '\0', '+', '#' -> expr.vars().size() == 1; 457 case '/', '.', '?', '&' -> true; 458 default -> false; 459 }; 460 } 461 462 /** 463 * Expands a reverse-matchable expression into its literal/capture piece sequence. Every branch here ends 464 * with a {@link CapturePiece}, which is what guarantees the "capture is always followed by a literal or the 465 * end of the template" invariant documented on the class and relied on by {@link #matchPieces(String)}. 466 */ 467 private static List<Piece> desugar(ParsedExpr expr) { 468 var out = new ArrayList<Piece>(); 469 switch (expr.operator()) { 470 case '\0' -> out.add(new CapturePiece(expr.vars().get(0).name(), STOP_SLASH)); 471 case '+' -> out.add(new CapturePiece(expr.vars().get(0).name(), STOP_NONE)); 472 case '#' -> { 473 out.add(new LiteralPiece("#")); 474 out.add(new CapturePiece(expr.vars().get(0).name(), STOP_NONE)); 475 } 476 case '/' -> { 477 for (var v : expr.vars()) { 478 out.add(new LiteralPiece("/")); 479 out.add(new CapturePiece(v.name(), STOP_SLASH)); 480 } 481 } 482 case '.' -> { 483 for (var v : expr.vars()) { 484 out.add(new LiteralPiece(".")); 485 out.add(new CapturePiece(v.name(), STOP_SLASH_DOT)); 486 } 487 } 488 case '?' -> { 489 var first = true; 490 for (var v : expr.vars()) { 491 out.add(new LiteralPiece((first ? "?" : "&") + v.name() + "=")); 492 out.add(new CapturePiece(v.name(), STOP_QUERY)); 493 first = false; 494 } 495 } 496 case '&' -> { 497 for (var v : expr.vars()) { 498 out.add(new LiteralPiece("&" + v.name() + "=")); 499 out.add(new CapturePiece(v.name(), STOP_QUERY)); 500 } 501 } 502 default -> { /* matrix and any other non-matchable operator: never reached, never used for matching */ } 503 } 504 return out; 505 } 506 507 //----------------------------------------------------------------------------------------------------------------- 508 // RFC 3986 percent-encoding normalization (implemented fresh; see class Javadoc) 509 //----------------------------------------------------------------------------------------------------------------- 510 511 private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); 512 513 private static boolean isUnreserved(int octet) { 514 return (octet >= 'A' && octet <= 'Z') || (octet >= 'a' && octet <= 'z') || (octet >= '0' && octet <= '9') 515 || octet == '-' || octet == '.' || octet == '_' || octet == '~'; 516 } 517 518 private static int hexValue(char c) { 519 if (c >= '0' && c <= '9') 520 return c - '0'; 521 if (c >= 'A' && c <= 'F') 522 return c - 'A' + 10; 523 if (c >= 'a' && c <= 'f') 524 return c - 'a' + 10; 525 return -1; 526 } 527 528 /** 529 * Normalizes a template literal segment at compile time: uppercases valid percent triplets, decodes 530 * percent-encoded unreserved octets to their literal character, and leaves percent-encoded reserved/other 531 * octets encoded. Also validates that the segment's percent-escaped bytes, combined with its raw characters, 532 * form valid UTF-8. Throws on any malformed escape or invalid UTF-8, naming the offending template. 533 */ 534 private static String normalizeLiteralStrict(String raw, String template) { 535 var sb = new StringBuilder(); 536 var bytes = new ByteArrayOutputStream(); 537 var n = raw.length(); 538 var i = 0; 539 while (i < n) { 540 var c = raw.charAt(i); 541 if (c == '%') { 542 if (i + 2 >= n || hexValue(raw.charAt(i + 1)) < 0 || hexValue(raw.charAt(i + 2)) < 0) 543 throw iaex("Malformed URI template ''%s'': malformed percent-escape at index %s", template, i); 544 var octet = (hexValue(raw.charAt(i + 1)) << 4) | hexValue(raw.charAt(i + 2)); 545 bytes.write(octet); 546 appendNormalizedOctet(sb, octet); 547 i += 3; 548 } else { 549 bytes.writeBytes(String.valueOf(c).getBytes(StandardCharsets.UTF_8)); 550 sb.append(c); 551 i++; 552 } 553 } 554 if (! isValidUtf8(bytes.toByteArray())) 555 throw iaex("Malformed URI template ''%s'': invalid UTF-8 percent-escape sequence in literal text", template); 556 return sb.toString(); 557 } 558 559 /** 560 * Normalizes an entire concrete URI at match time, using the same rules as 561 * {@link #normalizeLiteralStrict(String, String)} but returning <jk>null</jk> instead of throwing when a 562 * percent escape is malformed, per the rule that a concrete URI with a malformed escape simply does not 563 * template-match. 564 */ 565 private static String normalizeForMatch(String raw) { 566 var sb = new StringBuilder(); 567 var n = raw.length(); 568 var i = 0; 569 while (i < n) { 570 var c = raw.charAt(i); 571 if (c == '%') { 572 if (i + 2 >= n) 573 return null; 574 var h1 = hexValue(raw.charAt(i + 1)); 575 var h2 = hexValue(raw.charAt(i + 2)); 576 if (h1 < 0 || h2 < 0) 577 return null; 578 appendNormalizedOctet(sb, (h1 << 4) | h2); 579 i += 3; 580 } else { 581 sb.append(c); 582 i++; 583 } 584 } 585 return sb.toString(); 586 } 587 588 private static void appendNormalizedOctet(StringBuilder sb, int octet) { 589 if (isUnreserved(octet)) 590 sb.append((char) octet); 591 else 592 sb.append('%').append(HEX_DIGITS[(octet >> 4) & 0xF]).append(HEX_DIGITS[octet & 0xF]); 593 } 594 595 /** 596 * Counts normalized literal octets in an already-normalized string: each <c>%XX</c> triplet counts as one 597 * octet, and every other character counts as one octet. 598 */ 599 private static int countOctets(String normalized) { 600 var count = 0; 601 var i = 0; 602 var n = normalized.length(); 603 while (i < n) { 604 i += normalized.charAt(i) == '%' ? 3 : 1; 605 count++; 606 } 607 return count; 608 } 609 610 /** 611 * Percent-decodes an already-normalized capture substring to its final UTF-8 value for placement in the 612 * result map, rejecting (returning <jk>null</jk> for) captures whose decoded bytes are not valid UTF-8. 613 */ 614 private static String decodeCapture(String normalizedSubstring) { 615 var bytes = new ByteArrayOutputStream(); 616 var n = normalizedSubstring.length(); 617 var i = 0; 618 while (i < n) { 619 var c = normalizedSubstring.charAt(i); 620 if (c == '%') { 621 bytes.write((hexValue(normalizedSubstring.charAt(i + 1)) << 4) | hexValue(normalizedSubstring.charAt(i + 2))); 622 i += 3; 623 } else { 624 bytes.writeBytes(String.valueOf(c).getBytes(StandardCharsets.UTF_8)); 625 i++; 626 } 627 } 628 var data = bytes.toByteArray(); 629 if (! isValidUtf8(data)) 630 return null; 631 return new String(data, StandardCharsets.UTF_8); 632 } 633 634 private static boolean isValidUtf8(byte[] data) { 635 try { 636 StandardCharsets.UTF_8.newDecoder() 637 .onMalformedInput(CodingErrorAction.REPORT) 638 .onUnmappableCharacter(CodingErrorAction.REPORT) 639 .decode(ByteBuffer.wrap(data)); 640 return true; 641 } catch (CharacterCodingException e) { 642 return false; 643 } 644 } 645}