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 -> x + 1
121 * fatArrow: true # fat-arrow lambdas x => 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 }