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}