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 -> 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 */ 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}