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>{&amp;x}</c> / <c>{&amp;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>&amp;</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}