Source code
Description: source: package: org.apache.commons.jexl3, class: JexlConfigLoader

    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 *      https://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.commons.jexl3;
    018
    019import java.io.BufferedReader;
    020import java.io.IOException;
    021import java.io.InputStream;
    022import java.io.InputStreamReader;
    023import java.io.Reader;
    024import java.lang.reflect.InvocationTargetException;
    025import java.lang.reflect.Method;
    026import java.math.MathContext;
    027import java.nio.charset.Charset;
    028import java.nio.charset.StandardCharsets;
    029import java.util.ArrayList;
    030import java.util.LinkedHashMap;
    031import java.util.List;
    032import java.util.Map;
    033
    034import org.apache.commons.jexl3.introspection.JexlPermissions;
    035import org.apache.commons.jexl3.introspection.JexlUberspect;
    036
    037/**
    038 * Loads a YAML configuration file and applies it to a {@link JexlBuilder}.
    039 *
    040 * <p>Quick start:</p>
    041 * <pre>
    042 * try (InputStream in = getClass().getResourceAsStream("/jexl.yaml")) {
    043 *     JexlEngine engine = JexlConfigLoader.load(in).create();
    044 * }
    045 * </pre>
    046 *
    047 * <p>The loader understands a simple YAML subset: top-level scalars, one level of section nesting,
    048 * and list items.  No external YAML library is required.  Inline comments ({@code #} after
    049 * whitespace), empty lines, and single/double-quoted string values are supported.</p>
    050 *
    051 * <h2>Top-level scalar keys</h2>
    052 * <p>These map directly to the matching {@link JexlBuilder} setter.  Boolean values accept
    053 * {@code true/false}, {@code yes/no}, or {@code on/off}.  Example:</p>
    054 * <pre>
    055 * strict: true         # JexlBuilder.strict(true)
    056 * silent: false        # JexlBuilder.silent(false)
    057 * safe: true           # JexlBuilder.safe(true)   — enable safe-navigation ?.
    058 * cancellable: false   # JexlBuilder.cancellable(false)
    059 * antish: true         # JexlBuilder.antish(true) — resolve ant-style dotted names
    060 * lexical: true        # JexlBuilder.lexical(true)
    061 * lexicalShade: false  # JexlBuilder.lexicalShade(false)
    062 * strictInterpolation: false
    063 * booleanLogical: false
    064 * debug: true          # include source location in exceptions
    065 * cache: 512           # expression cache size (entries)
    066 * cacheThreshold: 64   # max expression length cached
    067 * stackOverflow: 512   # max recursion depth
    068 * collectMode: 1       # variable collection mode (0=off, 1=on)
    069 * charset: UTF-8       # source charset for script text
    070 * strategy: JEXL_STRATEGY   # property-resolver order: JEXL_STRATEGY or MAP_STRATEGY
    071 * </pre>
    072 *
    073 * <h2>{@code permissions:} section</h2>
    074 * <p>Controls which classes and members are visible to scripts.</p>
    075 * <pre>
    076 * permissions:
    077 *   base: SECURE           # NONE (default) | SECURE | RESTRICTED | UNRESTRICTED
    078 *   rules:                 # list of JexlPermissions DSL strings composed on top of base
    079 *     - "com.example.api +{}"           # allow whole package
    080 *     - "com.example.api +{ Foo{} }"    # allow specific class
    081 *     - "java.util +{ -Formatter { Formatter(); } }"  # deny one constructor
    082 *   classes:               # explicit fully-qualified class names to allow (ClassPermissions)
    083 *     - com.example.api.Foo
    084 *     - com.example.api.Bar
    085 *   logging: my.logger     # optional: wrap in LoggingPermissions; value = logger name
    086 *                          # bare "logging:" with no value uses the default logger
    087 * </pre>
    088 * <p>See {@link JexlPermissions#parse(String...)} for the DSL syntax,
    089 * {@link JexlPermissions#SECURE}, {@link JexlPermissions#RESTRICTED},
    090 * {@link JexlPermissions#logging()} for the logging wrapper.</p>
    091 *
    092 * <h2>{@code features:} section</h2>
    093 * <p>Controls which syntactic constructs are available at parse time.  Each key is the name of
    094 * a {@link JexlFeatures} boolean setter method; unknown keys are silently ignored.</p>
    095 * <pre>
    096 * features:
    097 *   # Script structure
    098 *   script: true             # multi-statement scripts (vs. single-expression mode)
    099 *   localVar: true           # local variable declarations (var x = ...)
    100 *   lambda: true             # lambda / named-function definitions
    101 *   loops: true              # for / while / do-while loops
    102 *   newInstance: true        # new(...) constructor calls
    103 *
    104 *   # Side-effects
    105 *   sideEffect: true         # any assignment or modification (=, +=, ...)
    106 *   sideEffectGlobal: true   # assignment to context / global variables
    107 *
    108 *   # Literals and operators
    109 *   structuredLiteral: true  # array [], map {}, set {} literals; ranges a..b
    110 *   arrayReferenceExpr: true # non-constant array index expressions (x[f()])
    111 *   methodCall: true         # method calls on objects (obj.method())
    112 *   annotation: true         # @annotation statements
    113 *   pragma: true             # #pragma directives
    114 *   pragmaAnywhere: true     # allow #pragma anywhere (not just at the top)
    115 *   namespacePragma: true    # #pragma jexl.namespace.ns ... syntax
    116 *   namespaceIdentifier: true# ns:fun(...) compact namespace call syntax
    117 *   importPragma: true       # #pragma jexl.import ... syntax
    118 *
    119 *   # Lambda arrow styles
    120 *   thinArrow: true          # thin-arrow lambdas  x -&gt; x + 1
    121 *   fatArrow: true           # fat-arrow lambdas   x =&gt; x + 1
    122 *
    123 *   # Variable capture semantics
    124 *   constCapture: true       # captured variables are read-only (Java-style)
    125 *   referenceCapture: false  # captured variables are pass-by-reference (ECMAScript-style)
    126 *
    127 *   # Lexical scoping (also settable at top level via 'lexical:' / 'lexicalShade:')
    128 *   lexical: true
    129 *   lexicalShade: false
    130 *
    131 *   # Misc
    132 *   comparatorNames: true    # allow 'gt', 'lt', 'ge', 'le', 'eq', 'ne' as operator aliases
    133 *   ambiguousStatement: true # allow statements that are syntactically ambiguous
    134 *   ignoreTemplatePrefix: false
    135 *
    136 *   # Reserved names (list — cannot be used as local variable or parameter names)
    137 *   reservedNames:
    138 *     - try
    139 *     - catch
    140 *     - class
    141 * </pre>
    142 *
    143 * <h2>{@code arithmetic:} section</h2>
    144 * <p>Selects and configures the {@link JexlArithmetic} implementation.</p>
    145 * <pre>
    146 * arithmetic:
    147 *   clazz: org.apache.commons.jexl3.JexlArithmetic  # fully-qualified class (default)
    148 *   strict: true       # strict arithmetic (true = default); passed to the constructor
    149 *   mathContext: DECIMAL64   # java.math.MathContext field: DECIMAL32 | DECIMAL64 | DECIMAL128 | UNLIMITED
    150 *   mathScale: 10      # BigDecimal scale; requires mathContext; -1 = use context default
    151 * </pre>
    152 * <p>When {@code mathContext} is present the constructor {@code (boolean, MathContext, int)} is used;
    153 * otherwise {@code (boolean)} is used.  The class must be on the classpath.</p>
    154 *
    155 * <h2>{@code namespaces:} section</h2>
    156 * <p>Maps namespace prefixes to fully-qualified class names.  The class is loaded and passed to
    157 * {@link JexlBuilder#namespaces(java.util.Map)}.  Its static (or instance) methods become
    158 * callable as {@code prefix:methodName(args)} from scripts.</p>
    159 * <pre>
    160 * namespaces:
    161 *   math: java.lang.Math          # math:abs(-1) etc.
    162 *   str:  com.example.StringUtils # str:trim(x) etc.
    163 * </pre>
    164 *
    165 * <h2>{@code imports:} section</h2>
    166 * <p>A list of package or class names passed to {@link JexlBuilder#imports(java.util.Collection)}.
    167 * Imported packages allow unqualified class names in {@code new(...)} and type references.</p>
    168 * <pre>
    169 * imports:
    170 *   - java.lang
    171 *   - java.util
    172 *   - com.example.api
    173 * </pre>
    174 *
    175 * <h2>Complete annotated example</h2>
    176 * <p>Every flag is listed explicitly so the configuration does not depend on any library default
    177 * (which may change between releases).  The {@code features:} block below reproduces the pre-3.7
    178 * feature set ({@link JexlFeatures#createDefault()}).</p>
    179 * <pre>
    180 * # Production engine — explicit permissions + legacy feature set
    181 * strict: true
    182 * safe: false
    183 * cache: 512
    184 *
    185 * permissions:
    186 *   base: RESTRICTED
    187 *   rules:
    188 *     - "com.example.api +{}"
    189 *   logging: com.example.jexl.permissions  # log allow/deny at INFO once per element
    190 *
    191 * features:
    192 *   script: true
    193 *   localVar: true
    194 *   lambda: true
    195 *   loops: true
    196 *   newInstance: true
    197 *   sideEffect: true
    198 *   sideEffectGlobal: true
    199 *   structuredLiteral: true
    200 *   arrayReferenceExpr: true
    201 *   methodCall: true
    202 *   annotation: true
    203 *   pragma: true
    204 *   pragmaAnywhere: true
    205 *   namespacePragma: true
    206 *   importPragma: true
    207 *   namespaceIdentifier: false
    208 *   thinArrow: true
    209 *   fatArrow: false
    210 *   constCapture: false
    211 *   referenceCapture: false
    212 *   lexical: false
    213 *   lexicalShade: false
    214 *   comparatorNames: true
    215 *   ambiguousStatement: false
    216 *   ignoreTemplatePrefix: false
    217 *
    218 * namespaces:
    219 *   math: java.lang.Math
    220 *
    221 * imports:
    222 *   - java.lang
    223 *   - java.util
    224 * </pre>
    225 *
    226 * @since 3.7.0
    227 */
    228public final class JexlConfigLoader {
    229
    230    private JexlConfigLoader() { }
    231
    232    /**
    233     * Loads configuration from a YAML {@link InputStream} (UTF-8) into a {@link JexlBuilder}.
    234     *
    235     * @param in YAML input; the caller is responsible for closing it
    236     * @return a configured JexlBuilder
    237     * @throws IOException if the stream cannot be read
    238     */
    239    public static JexlBuilder load(final InputStream in) throws IOException {
    240        return load(new InputStreamReader(in, StandardCharsets.UTF_8));
    241    }
    242
    243    /**
    244     * Loads configuration from a YAML {@link Reader} into a {@link JexlBuilder}.
    245     *
    246     * @param reader YAML input; the caller is responsible for closing it
    247     * @return a configured JexlBuilder
    248     * @throws IOException if the reader cannot be read
    249     */
    250    public static JexlBuilder load(final Reader reader) throws IOException {
    251        final JexlBuilder builder = new JexlBuilder();
    252        final JexlFeatures features = new JexlFeatures();
    253        parse(reader instanceof BufferedReader ? (BufferedReader) reader
    254            : new BufferedReader(reader), builder, features);
    255        return builder.features(features);
    256    }
    257
    258    /**
    259     * Convenience: loads YAML from {@code in} and creates the engine in one call.
    260     *
    261     * @param in YAML input; the caller is responsible for closing it
    262     * @return a new JexlEngine configured from the YAML
    263     * @throws IOException if the stream cannot be read
    264     */
    265    public static JexlEngine engine(final InputStream in) throws IOException {
    266        return load(in).create();
    267    }
    268
    269    // =========================================================================
    270    //  Parser
    271    // =========================================================================
    272
    273    /**
    274     * Minimal line-by-line YAML parser that handles the subset needed for JEXL configuration:
    275     * top-level key:value scalars, one-level section blocks, and list items within sections.
    276     */
    277    private static void parse(final BufferedReader reader,
    278                              final JexlBuilder builder,
    279                              final JexlFeatures features) throws IOException {
    280        String section = null;
    281        Map<String, Object> sectionData = null;
    282        List<String> currentList = null;
    283        String currentListKey = null;
    284
    285        for (String raw; (raw = reader.readLine()) != null; ) {
    286            final int indent = leadingSpaces(raw);
    287            final String stripped = ltrim(raw);
    288            if (stripped.isEmpty() || stripped.charAt(0) == '#') {
    289                continue;
    290            }
    291            final String line = stripComment(stripped);
    292            if (line.isEmpty()) {
    293                continue;
    294            }
    295
    296            if (indent == 0) {
    297                // back to top level — flush any open section
    298                if (section != null) {
    299                    if (currentList != null && currentListKey != null) {
    300                        sectionData.put(currentListKey, currentList);
    301                    }
    302                    flushSection(section, sectionData, builder, features);
    303                    section = null;
    304                    sectionData = null;
    305                    currentList = null;
    306                    currentListKey = null;
    307                }
    308
    309                final int colon = line.indexOf(':');
    310                final String key = colon < 0 ? line : rtrim(line.substring(0, colon));
    311                final String val = colon < 0 ? "" : ltrim(line.substring(colon + 1));
    312
    313                if (val.isEmpty()) {
    314                    section = key;
    315                    sectionData = new LinkedHashMap<>();
    316                } else {
    317                    applyTopLevel(key, val, builder);
    318                }
    319
    320            } else {
    321                // inside a section
    322                if (section == null) {
    323                    continue;
    324                }
    325
    326                if (line.charAt(0) == '-') {
    327                    // list item
    328                    final String item = unquote(ltrim(line.substring(1)));
    329                    if (currentList == null) {
    330                        currentList = new ArrayList<>();
    331                        currentListKey = section;
    332                    }
    333                    currentList.add(item);
    334                } else {
    335                    final int colon = line.indexOf(':');
    336                    final String key = colon < 0 ? line : rtrim(line.substring(0, colon));
    337                    final String val = colon < 0 ? "" : ltrim(line.substring(colon + 1));
    338
    339                    if (val.isEmpty()) {
    340                        // nested list-key start (e.g. "rules:")
    341                        if (currentList != null && currentListKey != null) {
    342                            sectionData.put(currentListKey, currentList);
    343                        }
    344                        currentListKey = key;
    345                        currentList = new ArrayList<>();
    346                    } else {
    347                        // scalar within section — close any pending list first
    348                        if (currentList != null && currentListKey != null) {
    349                            sectionData.put(currentListKey, currentList);
    350                            currentList = null;
    351                            currentListKey = null;
    352                        }
    353                        sectionData.put(key, val);
    354                    }
    355                }
    356            }
    357        }
    358
    359        // flush the last section
    360        if (section != null) {
    361            if (currentList != null && currentListKey != null) {
    362                sectionData.put(currentListKey, currentList);
    363            }
    364            flushSection(section, sectionData, builder, features);
    365        }
    366    }
    367
    368    // =========================================================================
    369    //  Section dispatchers
    370    // =========================================================================
    371
    372    @SuppressWarnings("unchecked")
    373    private static void flushSection(final String section,
    374                                     final Map<String, Object> data,
    375                                     final JexlBuilder builder,
    376                                     final JexlFeatures features) {
    377        switch (section) {
    378            case "permissions":
    379                applyPermissions(data, builder);
    380                break;
    381            case "namespaces":
    382                applyNamespaces(data, builder);
    383                break;
    384            case "arithmetic":
    385                applyArithmetic(data, builder);
    386                break;
    387            case "features":
    388                applyFeatures(data, features);
    389                break;
    390            case "imports": {
    391                final Object list = data.get(section);
    392                if (list instanceof List) {
    393                    builder.imports((List<String>) list);
    394                }
    395                break;
    396            }
    397            default:
    398                break;
    399        }
    400    }
    401
    402    private static void applyTopLevel(final String key,
    403                                      final String value,
    404                                      final JexlBuilder builder) {
    405        switch (key) {
    406            case "strict":              builder.strict(parseBool(value)); break;
    407            case "silent":              builder.silent(parseBool(value)); break;
    408            case "safe":                builder.safe(parseBool(value)); break;
    409            case "cancellable":         builder.cancellable(parseBool(value)); break;
    410            case "antish":              builder.antish(parseBool(value)); break;
    411            case "lexical":             builder.lexical(parseBool(value)); break;
    412            case "lexicalShade":        builder.lexicalShade(parseBool(value)); break;
    413            case "strictInterpolation": builder.strictInterpolation(parseBool(value)); break;
    414            case "booleanLogical":      builder.booleanLogical(parseBool(value)); break;
    415            case "debug":               builder.debug(parseBool(value)); break;
    416            case "cache":               builder.cache(parseInt(value)); break;
    417            case "cacheThreshold":      builder.cacheThreshold(parseInt(value)); break;
    418            case "stackOverflow":       builder.stackOverflow(parseInt(value)); break;
    419            case "collectMode":         builder.collectMode(parseInt(value)); break;
    420            case "charset":             builder.charset(Charset.forName(value)); break;
    421            case "strategy":            builder.strategy(parseStrategy(value)); break;
    422            default:
    423                break;
    424        }
    425    }
    426
    427    @SuppressWarnings("unchecked")
    428    private static void applyPermissions(final Map<String, Object> data,
    429                                         final JexlBuilder builder) {
    430        final Object baseVal = data.get("base");
    431        // absent or "NONE" → deny-everything base (build from scratch with rules/classes)
    432        JexlPermissions perms = "UNRESTRICTED".equals(baseVal) ? JexlPermissions.UNRESTRICTED
    433            : "SECURE".equals(baseVal) ? JexlPermissions.SECURE
    434            : "RESTRICTED".equals(baseVal) ? JexlPermissions.RESTRICTED
    435            : JexlPermissions.NONE;
    436        final Object rules = data.get("rules");
    437        if (rules instanceof List && !((List<?>) rules).isEmpty()) {
    438            final List<String> ruleList = (List<String>) rules;
    439            perms = perms.compose(ruleList.toArray(new String[0]));
    440        }
    441        final Object classes = data.get("classes");
    442        if (classes instanceof List && !((List<?>) classes).isEmpty()) {
    443            perms = new JexlPermissions.ClassPermissions(perms, (List<String>) classes);
    444        }
    445        // optional logging wrapper, outermost so it reports the effective decisions;
    446        // a String value names the logger, a bare "logging:" uses the default logger
    447        final Object logging = data.get("logging");
    448        if (logging != null) {
    449            final String name = logging instanceof String ? (String) logging : "";
    450            perms = name.isEmpty() ? perms.logging() : perms.logging(name);
    451        }
    452        builder.permissions(perms);
    453    }
    454
    455    private static void applyNamespaces(final Map<String, Object> data,
    456                                        final JexlBuilder builder) {
    457        final Map<String, Object> ns = new LinkedHashMap<>(data.size());
    458        for (final Map.Entry<String, Object> e : data.entrySet()) {
    459            if (e.getValue() instanceof String) {
    460                try {
    461                    ns.put(e.getKey(), Class.forName((String) e.getValue()));
    462                } catch (final ClassNotFoundException ex) {
    463                    throw new IllegalArgumentException(
    464                        "namespace class not found: " + e.getValue(), ex);
    465                }
    466            }
    467        }
    468        if (!ns.isEmpty()) {
    469            builder.namespaces(ns);
    470        }
    471    }
    472
    473    private static void applyArithmetic(final Map<String, Object> data,
    474                                        final JexlBuilder builder) {
    475        final String className = data.containsKey("clazz")
    476            ? (String) data.get("clazz")
    477            : JexlArithmetic.class.getName();
    478        final boolean astrict = !data.containsKey("strict") || parseBool((String) data.get("strict"));
    479        final Class<?> clazz;
    480        try {
    481            clazz = Class.forName(className);
    482        } catch (final ClassNotFoundException e) {
    483            throw new IllegalArgumentException("arithmetic class not found: " + className, e);
    484        }
    485        try {
    486            if (data.containsKey("mathContext")) {
    487                final MathContext mc = (MathContext)
    488                    MathContext.class.getField((String) data.get("mathContext")).get(null);
    489                final int scale = data.containsKey("mathScale")
    490                    ? parseInt((String) data.get("mathScale"))
    491                    : -1;
    492                builder.arithmetic((JexlArithmetic) clazz
    493                    .getConstructor(boolean.class, MathContext.class, int.class)
    494                    .newInstance(astrict, mc, scale));
    495            } else {
    496                builder.arithmetic((JexlArithmetic) clazz
    497                    .getConstructor(boolean.class)
    498                    .newInstance(astrict));
    499            }
    500        } catch (final ReflectiveOperationException e) {
    501            throw new IllegalArgumentException(
    502                "cannot instantiate arithmetic class " + className, e);
    503        }
    504    }
    505
    506    @SuppressWarnings("unchecked")
    507    private static void applyFeatures(final Map<String, Object> data,
    508                                      final JexlFeatures features) {
    509        for (final Map.Entry<String, Object> e : data.entrySet()) {
    510            final String key = e.getKey();
    511            final Object val = e.getValue();
    512            if ("reservedNames".equals(key) && val instanceof List) {
    513                features.reservedNames((List<String>) val);
    514            } else if (val instanceof String) {
    515                try {
    516                    final Method m = JexlFeatures.class.getMethod(key, boolean.class);
    517                    m.invoke(features, parseBool((String) val));
    518                } catch (final NoSuchMethodException ignored) {
    519                    // unknown feature key — skip
    520                } catch (final IllegalAccessException | InvocationTargetException ex) {
    521                    throw new IllegalArgumentException("cannot set feature " + key, ex);
    522                }
    523            }
    524        }
    525    }
    526
    527    // =========================================================================
    528    //  Parsing helpers
    529    // =========================================================================
    530
    531    private static JexlUberspect.ResolverStrategy parseStrategy(final String name) {
    532        return "MAP_STRATEGY".equals(name)
    533            ? JexlUberspect.MAP_STRATEGY
    534            : JexlUberspect.JEXL_STRATEGY;
    535    }
    536
    537    private static boolean parseBool(final String s) {
    538        return "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s);
    539    }
    540
    541    private static int parseInt(final String s) {
    542        return Integer.parseInt(s.trim());
    543    }
    544
    545    private static int leadingSpaces(final String s) {
    546        int i = 0;
    547        while (i < s.length() && (s.charAt(i) == ' ' || s.charAt(i) == '\t')) {
    548            i++;
    549        }
    550        return i;
    551    }
    552
    553    private static String ltrim(final String s) {
    554        int i = 0;
    555        while (i < s.length() && Character.isWhitespace(s.charAt(i))) {
    556            i++;
    557        }
    558        return i == 0 ? s : s.substring(i);
    559    }
    560
    561    private static String rtrim(final String s) {
    562        int i = s.length();
    563        while (i > 0 && Character.isWhitespace(s.charAt(i - 1))) {
    564            i--;
    565        }
    566        return i == s.length() ? s : s.substring(0, i);
    567    }
    568
    569    private static String stripComment(final String s) {
    570        boolean inDouble = false;
    571        boolean inSingle = false;
    572        for (int i = 0; i < s.length(); i++) {
    573            final char c = s.charAt(i);
    574            if (c == '"' && !inSingle) {
    575                inDouble = !inDouble;
    576            } else if (c == '\'' && !inDouble) {
    577                inSingle = !inSingle;
    578            } else if (c == '#' && !inDouble && !inSingle
    579                && i > 0 && Character.isWhitespace(s.charAt(i - 1))) {
    580                return rtrim(s.substring(0, i));
    581            }
    582        }
    583        return s;
    584    }
    585
    586    private static String unquote(final String s) {
    587        if (s.length() >= 2) {
    588            final char first = s.charAt(0);
    589            final char last = s.charAt(s.length() - 1);
    590            if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) {
    591                return s.substring(1, s.length() - 1);
    592            }
    593        }
    594        return s;
    595    }
    596}