View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.jexl3;
18  
19  import java.io.BufferedReader;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.io.InputStreamReader;
23  import java.io.Reader;
24  import java.lang.reflect.InvocationTargetException;
25  import java.lang.reflect.Method;
26  import java.math.MathContext;
27  import java.nio.charset.Charset;
28  import java.nio.charset.StandardCharsets;
29  import java.util.ArrayList;
30  import java.util.LinkedHashMap;
31  import java.util.List;
32  import java.util.Map;
33  
34  import org.apache.commons.jexl3.introspection.JexlPermissions;
35  import org.apache.commons.jexl3.introspection.JexlUberspect;
36  
37  /**
38   * Loads a YAML configuration file and applies it to a {@link JexlBuilder}.
39   *
40   * <p>Quick start:</p>
41   * <pre>
42   * try (InputStream in = getClass().getResourceAsStream("/jexl.yaml")) {
43   *     JexlEngine engine = JexlConfigLoader.load(in).create();
44   * }
45   * </pre>
46   *
47   * <p>The loader understands a simple YAML subset: top-level scalars, one level of section nesting,
48   * and list items.  No external YAML library is required.  Inline comments ({@code #} after
49   * whitespace), empty lines, and single/double-quoted string values are supported.</p>
50   *
51   * <h2>Top-level scalar keys</h2>
52   * <p>These map directly to the matching {@link JexlBuilder} setter.  Boolean values accept
53   * {@code true/false}, {@code yes/no}, or {@code on/off}.  Example:</p>
54   * <pre>
55   * strict: true         # JexlBuilder.strict(true)
56   * silent: false        # JexlBuilder.silent(false)
57   * safe: true           # JexlBuilder.safe(true)   — enable safe-navigation ?.
58   * cancellable: false   # JexlBuilder.cancellable(false)
59   * antish: true         # JexlBuilder.antish(true) — resolve ant-style dotted names
60   * lexical: true        # JexlBuilder.lexical(true)
61   * lexicalShade: false  # JexlBuilder.lexicalShade(false)
62   * strictInterpolation: false
63   * booleanLogical: false
64   * debug: true          # include source location in exceptions
65   * cache: 512           # expression cache size (entries)
66   * cacheThreshold: 64   # max expression length cached
67   * stackOverflow: 512   # max recursion depth
68   * collectMode: 1       # variable collection mode (0=off, 1=on)
69   * charset: UTF-8       # source charset for script text
70   * strategy: JEXL_STRATEGY   # property-resolver order: JEXL_STRATEGY or MAP_STRATEGY
71   * </pre>
72   *
73   * <h2>{@code permissions:} section</h2>
74   * <p>Controls which classes and members are visible to scripts.</p>
75   * <pre>
76   * permissions:
77   *   base: SECURE           # NONE (default) | SECURE | RESTRICTED | UNRESTRICTED
78   *   rules:                 # list of JexlPermissions DSL strings composed on top of base
79   *     - "com.example.api +{}"           # allow whole package
80   *     - "com.example.api +{ Foo{} }"    # allow specific class
81   *     - "java.util +{ -Formatter { Formatter(); } }"  # deny one constructor
82   *   classes:               # explicit fully-qualified class names to allow (ClassPermissions)
83   *     - com.example.api.Foo
84   *     - com.example.api.Bar
85   *   logging: my.logger     # optional: wrap in LoggingPermissions; value = logger name
86   *                          # bare "logging:" with no value uses the default logger
87   * </pre>
88   * <p>See {@link JexlPermissions#parse(String...)} for the DSL syntax,
89   * {@link JexlPermissions#SECURE}, {@link JexlPermissions#RESTRICTED},
90   * {@link JexlPermissions#logging()} for the logging wrapper.</p>
91   *
92   * <h2>{@code features:} section</h2>
93   * <p>Controls which syntactic constructs are available at parse time.  Each key is the name of
94   * a {@link JexlFeatures} boolean setter method; unknown keys are silently ignored.</p>
95   * <pre>
96   * features:
97   *   # Script structure
98   *   script: true             # multi-statement scripts (vs. single-expression mode)
99   *   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  */
228 public 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 }