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.commons.svl.functions;
018
019import static java.nio.charset.StandardCharsets.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.net.*;
023import java.util.*;
024
025import org.apache.juneau.commons.svl.*;
026
027/**
028 * Encoding / escaping functions for the {@code #{...}} script catalog.
029 *
030 * <p>
031 * Includes Base64 encode/decode, URL-encoded-form encode/decode, and HTML-injection-safe
032 * escape/unescape.
033 */
034public final class EncodingFunctions {
035
036   private EncodingFunctions() {}
037
038   /** All function classes in this category. */
039   @SuppressWarnings({
040      "java:S2386", // ALL is an immutable compile-time registry; exposed as an array for the cross-package/varargs functions(...) API, so visibility cannot be reduced.
041      "unchecked" // Cast is safe: type verified by caller context.
042   })
043   public static final Class<? extends VarFunction>[] ALL = new Class[] {
044      Base64Encode.class, Base64Decode.class, UrlEncode.class, UrlDecode.class,
045      HtmlEscape.class, HtmlUnescape.class
046   };
047
048   /** {@code #{base64Encode(s)}} — Base64-encodes {@code s} (UTF-8). */
049   public static class Base64Encode extends TypedFunction {
050      @Override public String name() { return "base64Encode"; }
051      public String invoke(String s) {
052         if (s == null) return "";
053         return Base64.getEncoder().encodeToString(s.getBytes(UTF_8));
054      }
055   }
056
057   /** {@code #{base64Decode(s)}} — Base64-decodes {@code s} (UTF-8 result). */
058   public static class Base64Decode extends TypedFunction {
059      @Override public String name() { return "base64Decode"; }
060      public String invoke(String s) {
061         if (isEmpty(s)) return "";
062         return new String(Base64.getDecoder().decode(s), UTF_8);
063      }
064   }
065
066   /** {@code #{urlEncode(s)}} — URL-form-encodes (i.e. {@link URLEncoder#encode(String, java.nio.charset.Charset)}). */
067   public static class UrlEncode extends TypedFunction {
068      @Override public String name() { return "urlEncode"; }
069      public String invoke(String s) {
070         return s == null ? "" : URLEncoder.encode(s, UTF_8);
071      }
072   }
073
074   /** {@code #{urlDecode(s)}} — URL-form-decodes. */
075   public static class UrlDecode extends TypedFunction {
076      @Override public String name() { return "urlDecode"; }
077      public String invoke(String s) {
078         return s == null ? "" : URLDecoder.decode(s, UTF_8);
079      }
080   }
081
082   /**
083    * {@code #{htmlEscape(s)}} — escapes {@code &}, {@code <}, {@code >}, {@code "}, {@code '}
084    * (the OWASP minimal HTML-injection-safe set).
085    */
086   public static class HtmlEscape extends TypedFunction {
087      @Override public String name() { return "htmlEscape"; }
088      public String invoke(String s) {
089         if (s == null) return "";
090         var sb = new StringBuilder(s.length() + 16);
091         for (var i = 0; i < s.length(); i++) {
092            var c = s.charAt(i);
093            switch (c) {
094               case '&': sb.append("&amp;"); break;
095               case '<': sb.append("&lt;"); break;
096               case '>': sb.append("&gt;"); break;
097               case '"': sb.append("&quot;"); break;
098               case '\'': sb.append("&#39;"); break;
099               default: sb.append(c); break;
100            }
101         }
102         return sb.toString();
103      }
104   }
105
106   /**
107    * {@code #{htmlUnescape(s)}} — inverse of {@link HtmlEscape}. Supports numeric
108    * ({@code &#NNNN;}) and named ({@code &amp;} / {@code &lt;} / {@code &gt;} /
109    * {@code &quot;} / {@code &apos;} / {@code &#39;}) entities.
110    */
111   @SuppressWarnings({
112      "java:S3776" // Cognitive complexity: small inline entity decoder.
113   })
114   public static class HtmlUnescape extends TypedFunction {
115      @Override public String name() { return "htmlUnescape"; }
116      @SuppressWarnings({
117         "java:S135" // State-machine entity-decode loop; early continues are clearer than restructuring.
118      })
119      public String invoke(String s) {
120         if (s == null) return "";
121         var sb = new StringBuilder(s.length());
122         var i = 0;
123         while (i < s.length()) {
124            var c = s.charAt(i);
125            if (c != '&') { sb.append(c); i++; continue; }
126            var end = s.indexOf(';', i + 1);
127            if (end < 0) { sb.append(c); i++; continue; }
128            var entity = s.substring(i + 1, end);
129            var decoded = decode(entity);
130            if (decoded == null) {
131               sb.append(c);
132               i++;
133            } else {
134               sb.append(decoded);
135               i = end + 1;
136            }
137         }
138         return sb.toString();
139      }
140
141      private static String decode(String entity) {
142         if (entity.isEmpty()) return null;
143         if (entity.charAt(0) == '#') {
144            try {
145               int cp;
146               if (entity.length() > 1 && (entity.charAt(1) == 'x' || entity.charAt(1) == 'X'))
147                  cp = Integer.parseInt(entity.substring(2), 16);
148               else
149                  cp = Integer.parseInt(entity.substring(1));
150               return new String(Character.toChars(cp));
151            } catch (@SuppressWarnings("unused") NumberFormatException e) {
152               return null;
153            }
154         }
155         switch (entity) {
156            case "amp":   return "&";
157            case "lt":    return "<";
158            case "gt":    return ">";
159            case "quot":  return "\"";
160            case "apos":  return "'";
161            default:      return null;
162         }
163      }
164   }
165}