JexlConfigLoader.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.commons.jexl3;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.MathContext;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.jexl3.introspection.JexlPermissions;
import org.apache.commons.jexl3.introspection.JexlUberspect;
/**
* Loads a YAML configuration file and applies it to a {@link JexlBuilder}.
*
* <p>Quick start:</p>
* <pre>
* try (InputStream in = getClass().getResourceAsStream("/jexl.yaml")) {
* JexlEngine engine = JexlConfigLoader.load(in).create();
* }
* </pre>
*
* <p>The loader understands a simple YAML subset: top-level scalars, one level of section nesting,
* and list items. No external YAML library is required. Inline comments ({@code #} after
* whitespace), empty lines, and single/double-quoted string values are supported.</p>
*
* <h2>Top-level scalar keys</h2>
* <p>These map directly to the matching {@link JexlBuilder} setter. Boolean values accept
* {@code true/false}, {@code yes/no}, or {@code on/off}. Example:</p>
* <pre>
* strict: true # JexlBuilder.strict(true)
* silent: false # JexlBuilder.silent(false)
* safe: true # JexlBuilder.safe(true) — enable safe-navigation ?.
* cancellable: false # JexlBuilder.cancellable(false)
* antish: true # JexlBuilder.antish(true) — resolve ant-style dotted names
* lexical: true # JexlBuilder.lexical(true)
* lexicalShade: false # JexlBuilder.lexicalShade(false)
* strictInterpolation: false
* booleanLogical: false
* debug: true # include source location in exceptions
* cache: 512 # expression cache size (entries)
* cacheThreshold: 64 # max expression length cached
* stackOverflow: 512 # max recursion depth
* collectMode: 1 # variable collection mode (0=off, 1=on)
* charset: UTF-8 # source charset for script text
* strategy: JEXL_STRATEGY # property-resolver order: JEXL_STRATEGY or MAP_STRATEGY
* </pre>
*
* <h2>{@code permissions:} section</h2>
* <p>Controls which classes and members are visible to scripts.</p>
* <pre>
* permissions:
* base: SECURE # NONE (default) | SECURE | RESTRICTED | UNRESTRICTED
* rules: # list of JexlPermissions DSL strings composed on top of base
* - "com.example.api +{}" # allow whole package
* - "com.example.api +{ Foo{} }" # allow specific class
* - "java.util +{ -Formatter { Formatter(); } }" # deny one constructor
* classes: # explicit fully-qualified class names to allow (ClassPermissions)
* - com.example.api.Foo
* - com.example.api.Bar
* logging: my.logger # optional: wrap in LoggingPermissions; value = logger name
* # bare "logging:" with no value uses the default logger
* </pre>
* <p>See {@link JexlPermissions#parse(String...)} for the DSL syntax,
* {@link JexlPermissions#SECURE}, {@link JexlPermissions#RESTRICTED},
* {@link JexlPermissions#logging()} for the logging wrapper.</p>
*
* <h2>{@code features:} section</h2>
* <p>Controls which syntactic constructs are available at parse time. Each key is the name of
* a {@link JexlFeatures} boolean setter method; unknown keys are silently ignored.</p>
* <pre>
* features:
* # Script structure
* script: true # multi-statement scripts (vs. single-expression mode)
* localVar: true # local variable declarations (var x = ...)
* lambda: true # lambda / named-function definitions
* loops: true # for / while / do-while loops
* newInstance: true # new(...) constructor calls
*
* # Side-effects
* sideEffect: true # any assignment or modification (=, +=, ...)
* sideEffectGlobal: true # assignment to context / global variables
*
* # Literals and operators
* structuredLiteral: true # array [], map {}, set {} literals; ranges a..b
* arrayReferenceExpr: true # non-constant array index expressions (x[f()])
* methodCall: true # method calls on objects (obj.method())
* annotation: true # @annotation statements
* pragma: true # #pragma directives
* pragmaAnywhere: true # allow #pragma anywhere (not just at the top)
* namespacePragma: true # #pragma jexl.namespace.ns ... syntax
* namespaceIdentifier: true# ns:fun(...) compact namespace call syntax
* importPragma: true # #pragma jexl.import ... syntax
*
* # Lambda arrow styles
* thinArrow: true # thin-arrow lambdas x -> x + 1
* fatArrow: true # fat-arrow lambdas x => x + 1
*
* # Variable capture semantics
* constCapture: true # captured variables are read-only (Java-style)
* referenceCapture: false # captured variables are pass-by-reference (ECMAScript-style)
*
* # Lexical scoping (also settable at top level via 'lexical:' / 'lexicalShade:')
* lexical: true
* lexicalShade: false
*
* # Misc
* comparatorNames: true # allow 'gt', 'lt', 'ge', 'le', 'eq', 'ne' as operator aliases
* ambiguousStatement: true # allow statements that are syntactically ambiguous
* ignoreTemplatePrefix: false
*
* # Reserved names (list — cannot be used as local variable or parameter names)
* reservedNames:
* - try
* - catch
* - class
* </pre>
*
* <h2>{@code arithmetic:} section</h2>
* <p>Selects and configures the {@link JexlArithmetic} implementation.</p>
* <pre>
* arithmetic:
* clazz: org.apache.commons.jexl3.JexlArithmetic # fully-qualified class (default)
* strict: true # strict arithmetic (true = default); passed to the constructor
* mathContext: DECIMAL64 # java.math.MathContext field: DECIMAL32 | DECIMAL64 | DECIMAL128 | UNLIMITED
* mathScale: 10 # BigDecimal scale; requires mathContext; -1 = use context default
* </pre>
* <p>When {@code mathContext} is present the constructor {@code (boolean, MathContext, int)} is used;
* otherwise {@code (boolean)} is used. The class must be on the classpath.</p>
*
* <h2>{@code namespaces:} section</h2>
* <p>Maps namespace prefixes to fully-qualified class names. The class is loaded and passed to
* {@link JexlBuilder#namespaces(java.util.Map)}. Its static (or instance) methods become
* callable as {@code prefix:methodName(args)} from scripts.</p>
* <pre>
* namespaces:
* math: java.lang.Math # math:abs(-1) etc.
* str: com.example.StringUtils # str:trim(x) etc.
* </pre>
*
* <h2>{@code imports:} section</h2>
* <p>A list of package or class names passed to {@link JexlBuilder#imports(java.util.Collection)}.
* Imported packages allow unqualified class names in {@code new(...)} and type references.</p>
* <pre>
* imports:
* - java.lang
* - java.util
* - com.example.api
* </pre>
*
* <h2>Complete annotated example</h2>
* <p>Every flag is listed explicitly so the configuration does not depend on any library default
* (which may change between releases). The {@code features:} block below reproduces the pre-3.7
* feature set ({@link JexlFeatures#createDefault()}).</p>
* <pre>
* # Production engine — explicit permissions + legacy feature set
* strict: true
* safe: false
* cache: 512
*
* permissions:
* base: RESTRICTED
* rules:
* - "com.example.api +{}"
* logging: com.example.jexl.permissions # log allow/deny at INFO once per element
*
* features:
* script: true
* localVar: true
* lambda: true
* loops: true
* newInstance: true
* sideEffect: true
* sideEffectGlobal: true
* structuredLiteral: true
* arrayReferenceExpr: true
* methodCall: true
* annotation: true
* pragma: true
* pragmaAnywhere: true
* namespacePragma: true
* importPragma: true
* namespaceIdentifier: false
* thinArrow: true
* fatArrow: false
* constCapture: false
* referenceCapture: false
* lexical: false
* lexicalShade: false
* comparatorNames: true
* ambiguousStatement: false
* ignoreTemplatePrefix: false
*
* namespaces:
* math: java.lang.Math
*
* imports:
* - java.lang
* - java.util
* </pre>
*
* @since 3.7.0
*/
public final class JexlConfigLoader {
private JexlConfigLoader() { }
/**
* Loads configuration from a YAML {@link InputStream} (UTF-8) into a {@link JexlBuilder}.
*
* @param in YAML input; the caller is responsible for closing it
* @return a configured JexlBuilder
* @throws IOException if the stream cannot be read
*/
public static JexlBuilder load(final InputStream in) throws IOException {
return load(new InputStreamReader(in, StandardCharsets.UTF_8));
}
/**
* Loads configuration from a YAML {@link Reader} into a {@link JexlBuilder}.
*
* @param reader YAML input; the caller is responsible for closing it
* @return a configured JexlBuilder
* @throws IOException if the reader cannot be read
*/
public static JexlBuilder load(final Reader reader) throws IOException {
final JexlBuilder builder = new JexlBuilder();
final JexlFeatures features = new JexlFeatures();
parse(reader instanceof BufferedReader ? (BufferedReader) reader
: new BufferedReader(reader), builder, features);
return builder.features(features);
}
/**
* Convenience: loads YAML from {@code in} and creates the engine in one call.
*
* @param in YAML input; the caller is responsible for closing it
* @return a new JexlEngine configured from the YAML
* @throws IOException if the stream cannot be read
*/
public static JexlEngine engine(final InputStream in) throws IOException {
return load(in).create();
}
// =========================================================================
// Parser
// =========================================================================
/**
* Minimal line-by-line YAML parser that handles the subset needed for JEXL configuration:
* top-level key:value scalars, one-level section blocks, and list items within sections.
*/
private static void parse(final BufferedReader reader,
final JexlBuilder builder,
final JexlFeatures features) throws IOException {
String section = null;
Map<String, Object> sectionData = null;
List<String> currentList = null;
String currentListKey = null;
for (String raw; (raw = reader.readLine()) != null; ) {
final int indent = leadingSpaces(raw);
final String stripped = ltrim(raw);
if (stripped.isEmpty() || stripped.charAt(0) == '#') {
continue;
}
final String line = stripComment(stripped);
if (line.isEmpty()) {
continue;
}
if (indent == 0) {
// back to top level — flush any open section
if (section != null) {
if (currentList != null && currentListKey != null) {
sectionData.put(currentListKey, currentList);
}
flushSection(section, sectionData, builder, features);
section = null;
sectionData = null;
currentList = null;
currentListKey = null;
}
final int colon = line.indexOf(':');
final String key = colon < 0 ? line : rtrim(line.substring(0, colon));
final String val = colon < 0 ? "" : ltrim(line.substring(colon + 1));
if (val.isEmpty()) {
section = key;
sectionData = new LinkedHashMap<>();
} else {
applyTopLevel(key, val, builder);
}
} else {
// inside a section
if (section == null) {
continue;
}
if (line.charAt(0) == '-') {
// list item
final String item = unquote(ltrim(line.substring(1)));
if (currentList == null) {
currentList = new ArrayList<>();
currentListKey = section;
}
currentList.add(item);
} else {
final int colon = line.indexOf(':');
final String key = colon < 0 ? line : rtrim(line.substring(0, colon));
final String val = colon < 0 ? "" : ltrim(line.substring(colon + 1));
if (val.isEmpty()) {
// nested list-key start (e.g. "rules:")
if (currentList != null && currentListKey != null) {
sectionData.put(currentListKey, currentList);
}
currentListKey = key;
currentList = new ArrayList<>();
} else {
// scalar within section — close any pending list first
if (currentList != null && currentListKey != null) {
sectionData.put(currentListKey, currentList);
currentList = null;
currentListKey = null;
}
sectionData.put(key, val);
}
}
}
}
// flush the last section
if (section != null) {
if (currentList != null && currentListKey != null) {
sectionData.put(currentListKey, currentList);
}
flushSection(section, sectionData, builder, features);
}
}
// =========================================================================
// Section dispatchers
// =========================================================================
@SuppressWarnings("unchecked")
private static void flushSection(final String section,
final Map<String, Object> data,
final JexlBuilder builder,
final JexlFeatures features) {
switch (section) {
case "permissions":
applyPermissions(data, builder);
break;
case "namespaces":
applyNamespaces(data, builder);
break;
case "arithmetic":
applyArithmetic(data, builder);
break;
case "features":
applyFeatures(data, features);
break;
case "imports": {
final Object list = data.get(section);
if (list instanceof List) {
builder.imports((List<String>) list);
}
break;
}
default:
break;
}
}
private static void applyTopLevel(final String key,
final String value,
final JexlBuilder builder) {
switch (key) {
case "strict": builder.strict(parseBool(value)); break;
case "silent": builder.silent(parseBool(value)); break;
case "safe": builder.safe(parseBool(value)); break;
case "cancellable": builder.cancellable(parseBool(value)); break;
case "antish": builder.antish(parseBool(value)); break;
case "lexical": builder.lexical(parseBool(value)); break;
case "lexicalShade": builder.lexicalShade(parseBool(value)); break;
case "strictInterpolation": builder.strictInterpolation(parseBool(value)); break;
case "booleanLogical": builder.booleanLogical(parseBool(value)); break;
case "debug": builder.debug(parseBool(value)); break;
case "cache": builder.cache(parseInt(value)); break;
case "cacheThreshold": builder.cacheThreshold(parseInt(value)); break;
case "stackOverflow": builder.stackOverflow(parseInt(value)); break;
case "collectMode": builder.collectMode(parseInt(value)); break;
case "charset": builder.charset(Charset.forName(value)); break;
case "strategy": builder.strategy(parseStrategy(value)); break;
default:
break;
}
}
@SuppressWarnings("unchecked")
private static void applyPermissions(final Map<String, Object> data,
final JexlBuilder builder) {
final Object baseVal = data.get("base");
// absent or "NONE" → deny-everything base (build from scratch with rules/classes)
JexlPermissions perms = "UNRESTRICTED".equals(baseVal) ? JexlPermissions.UNRESTRICTED
: "SECURE".equals(baseVal) ? JexlPermissions.SECURE
: "RESTRICTED".equals(baseVal) ? JexlPermissions.RESTRICTED
: JexlPermissions.NONE;
final Object rules = data.get("rules");
if (rules instanceof List && !((List<?>) rules).isEmpty()) {
final List<String> ruleList = (List<String>) rules;
perms = perms.compose(ruleList.toArray(new String[0]));
}
final Object classes = data.get("classes");
if (classes instanceof List && !((List<?>) classes).isEmpty()) {
perms = new JexlPermissions.ClassPermissions(perms, (List<String>) classes);
}
// optional logging wrapper, outermost so it reports the effective decisions;
// a String value names the logger, a bare "logging:" uses the default logger
final Object logging = data.get("logging");
if (logging != null) {
final String name = logging instanceof String ? (String) logging : "";
perms = name.isEmpty() ? perms.logging() : perms.logging(name);
}
builder.permissions(perms);
}
private static void applyNamespaces(final Map<String, Object> data,
final JexlBuilder builder) {
final Map<String, Object> ns = new LinkedHashMap<>(data.size());
for (final Map.Entry<String, Object> e : data.entrySet()) {
if (e.getValue() instanceof String) {
try {
ns.put(e.getKey(), Class.forName((String) e.getValue()));
} catch (final ClassNotFoundException ex) {
throw new IllegalArgumentException(
"namespace class not found: " + e.getValue(), ex);
}
}
}
if (!ns.isEmpty()) {
builder.namespaces(ns);
}
}
private static void applyArithmetic(final Map<String, Object> data,
final JexlBuilder builder) {
final String className = data.containsKey("clazz")
? (String) data.get("clazz")
: JexlArithmetic.class.getName();
final boolean astrict = !data.containsKey("strict") || parseBool((String) data.get("strict"));
final Class<?> clazz;
try {
clazz = Class.forName(className);
} catch (final ClassNotFoundException e) {
throw new IllegalArgumentException("arithmetic class not found: " + className, e);
}
try {
if (data.containsKey("mathContext")) {
final MathContext mc = (MathContext)
MathContext.class.getField((String) data.get("mathContext")).get(null);
final int scale = data.containsKey("mathScale")
? parseInt((String) data.get("mathScale"))
: -1;
builder.arithmetic((JexlArithmetic) clazz
.getConstructor(boolean.class, MathContext.class, int.class)
.newInstance(astrict, mc, scale));
} else {
builder.arithmetic((JexlArithmetic) clazz
.getConstructor(boolean.class)
.newInstance(astrict));
}
} catch (final ReflectiveOperationException e) {
throw new IllegalArgumentException(
"cannot instantiate arithmetic class " + className, e);
}
}
@SuppressWarnings("unchecked")
private static void applyFeatures(final Map<String, Object> data,
final JexlFeatures features) {
for (final Map.Entry<String, Object> e : data.entrySet()) {
final String key = e.getKey();
final Object val = e.getValue();
if ("reservedNames".equals(key) && val instanceof List) {
features.reservedNames((List<String>) val);
} else if (val instanceof String) {
try {
final Method m = JexlFeatures.class.getMethod(key, boolean.class);
m.invoke(features, parseBool((String) val));
} catch (final NoSuchMethodException ignored) {
// unknown feature key — skip
} catch (final IllegalAccessException | InvocationTargetException ex) {
throw new IllegalArgumentException("cannot set feature " + key, ex);
}
}
}
}
// =========================================================================
// Parsing helpers
// =========================================================================
private static JexlUberspect.ResolverStrategy parseStrategy(final String name) {
return "MAP_STRATEGY".equals(name)
? JexlUberspect.MAP_STRATEGY
: JexlUberspect.JEXL_STRATEGY;
}
private static boolean parseBool(final String s) {
return "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s);
}
private static int parseInt(final String s) {
return Integer.parseInt(s.trim());
}
private static int leadingSpaces(final String s) {
int i = 0;
while (i < s.length() && (s.charAt(i) == ' ' || s.charAt(i) == '\t')) {
i++;
}
return i;
}
private static String ltrim(final String s) {
int i = 0;
while (i < s.length() && Character.isWhitespace(s.charAt(i))) {
i++;
}
return i == 0 ? s : s.substring(i);
}
private static String rtrim(final String s) {
int i = s.length();
while (i > 0 && Character.isWhitespace(s.charAt(i - 1))) {
i--;
}
return i == s.length() ? s : s.substring(0, i);
}
private static String stripComment(final String s) {
boolean inDouble = false;
boolean inSingle = false;
for (int i = 0; i < s.length(); i++) {
final char c = s.charAt(i);
if (c == '"' && !inSingle) {
inDouble = !inDouble;
} else if (c == '\'' && !inDouble) {
inSingle = !inSingle;
} else if (c == '#' && !inDouble && !inSingle
&& i > 0 && Character.isWhitespace(s.charAt(i - 1))) {
return rtrim(s.substring(0, i));
}
}
return s;
}
private static String unquote(final String s) {
if (s.length() >= 2) {
final char first = s.charAt(0);
final char last = s.charAt(s.length() - 1);
if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) {
return s.substring(1, s.length() - 1);
}
}
return s;
}
}