From 20409257f149959cd13fe11802792558209723b1 Mon Sep 17 00:00:00 2001
From: Rene Schwietzke
Date: Tue, 27 Jan 2026 00:31:07 +0100
Subject: [PATCH 1/4] Feature property expansion via Groovy for calculations
---
PROPERTY_EXPANSION.md | 191 +++++++++++++
doc/3rd-party-licenses/groovy/LICENSE.txt | 202 ++++++++++++++
doc/3rd-party-licenses/groovy/NOTICE.txt | 5 +
pom.xml | 6 +
.../common/util/GroovyPropertyEvaluator.java | 251 ++++++++++++++++++
.../common/util/PropertiesUtils.java | 107 +++++---
.../util/PropertyGroovySecurityUtils.java | 99 +++++++
.../common/util/PropertiesUtilsTest.java | 159 ++++++++++-
8 files changed, 980 insertions(+), 40 deletions(-)
create mode 100644 PROPERTY_EXPANSION.md
create mode 100644 doc/3rd-party-licenses/groovy/LICENSE.txt
create mode 100644 doc/3rd-party-licenses/groovy/NOTICE.txt
create mode 100644 src/main/java/com/xceptance/common/util/GroovyPropertyEvaluator.java
create mode 100644 src/main/java/com/xceptance/common/util/PropertyGroovySecurityUtils.java
diff --git a/PROPERTY_EXPANSION.md b/PROPERTY_EXPANSION.md
new file mode 100644
index 000000000..6d751dbd4
--- /dev/null
+++ b/PROPERTY_EXPANSION.md
@@ -0,0 +1,191 @@
+# Property Expansion with Groovy Expressions
+
+XLT properties support dynamic value expansion using Groovy expressions. This allows you to calculate load mixes, define relationships between properties, and share computed values across your configuration.
+
+## Getting Started
+
+In any properties file, you can use two expansion syntaxes:
+
+* **`${property.name}`** - Standard variable substitution (references other properties)
+* **`#{...}`** - Groovy expression evaluation (Spring-like syntax)
+
+```properties
+# Standard variable substitution
+base.url = https://example.com
+login.url = ${base.url}/login
+
+# Groovy expression
+totalUsers = 100
+com.xceptance.xlt.loadtests.TBrowse.users = #{ (props['totalUsers'] as int) * 0.4 }
+```
+
+## Evaluation Order
+
+1. **`${...}`** variable references are resolved first
+2. **`#{...}`** Groovy expressions are evaluated second
+
+Since `${}` is resolved before Groovy evaluation, you can use property values directly without needing `props`:
+
+```properties
+maxUsers = 100
+
+# Preferred: use ${} for simple property access
+browse.users = #{ ${maxUsers} * 0.4 } # Results in: 40.0
+
+# Equivalent but more verbose
+browse.users = #{ props['maxUsers'] as int * 0.4 }
+```
+
+> **Tip**: Use `${}` when you just need the property value. Use `props` only when you need dynamic key lookup or default values.
+
+## Available Bindings
+
+Groovy expressions have access to two variables:
+
+### `props` - Property Access (When Needed)
+
+Use `props` when you need dynamic key lookup or default values:
+
+```properties
+# Dynamic key lookup
+env = production
+value = #{ props.getProperty("limit.${props['env']}", '100') as int }
+
+# Default values
+orderUsers = #{ props.getProperty('order.users', '10') as int }
+```
+
+For simple property access, prefer `${}` syntax instead:
+
+```properties
+totalUsers = 100
+
+# Preferred
+browseUsers = #{ ${totalUsers} * 0.4 }
+
+# Also works, but more verbose
+browseUsers = #{ props['totalUsers'] as int * 0.4 }
+```
+
+### `ctx` - Shared Context Map
+
+Store and share values between expressions:
+
+```properties
+# Store computed values
+init = #{
+ ctx['base'] = props['totalUsers'] as int
+ ctx['loaded'] = true
+ 'initialized'
+}
+
+# Use stored values later
+browse.users = #{ ctx['base'] * 0.4 }
+order.users = #{ ctx['base'] * 0.1 }
+```
+
+## Multi-Line Scripts
+
+Groovy expressions can span multiple lines:
+
+```properties
+com.xceptance.xlt.loadtests.config = #{
+ def total = props['totalUsers'] as int
+ def browse = (total * 0.4) as int
+ def order = (total * 0.1) as int
+
+ ctx['totalUsers'] = total
+ ctx['browseUsers'] = browse
+ ctx['orderUsers'] = order
+
+ 'configured'
+}
+
+com.xceptance.xlt.loadtests.TBrowse.users = #{ ctx['browseUsers'] }
+com.xceptance.xlt.loadtests.TOrder.users = #{ ctx['orderUsers'] }
+```
+
+## Practical Examples
+
+### Load Mix Calculation
+
+```properties
+# Base configuration
+totalUsers = 100
+
+# Calculate user distribution using ${} (preferred)
+com.xceptance.xlt.loadtests.TBrowse.users = #{ ${totalUsers} * 0.40 as int }
+com.xceptance.xlt.loadtests.TSearch.users = #{ ${totalUsers} * 0.30 as int }
+com.xceptance.xlt.loadtests.TOrder.users = #{ ${totalUsers} * 0.10 as int }
+com.xceptance.xlt.loadtests.TCheckout.users = #{ ${totalUsers} * 0.20 as int }
+```
+
+### Environment-Based Configuration
+
+```properties
+# Environment multiplier (override per environment)
+load.multiplier = 1.0
+base.browse.users = 50
+
+# Scale by multiplier using ${}
+com.xceptance.xlt.loadtests.TBrowse.users = #{ ${base.browse.users} * ${load.multiplier} as int }
+```
+
+### Dynamic Arrival Rates
+
+```properties
+hourlyRequests = 10000
+testDurationHours = 1
+
+# Calculate arrival rate per hour
+com.xceptance.xlt.loadtests.TSearch.arrivalRate = #{
+ props['hourlyRequests'] as int
+}
+```
+
+## Supported Operations
+
+Groovy expressions support:
+
+* **Arithmetic**: `+`, `-`, `*`, `/`, `%`
+* **Type conversion**: `as int`, `as double`, `as String`
+* **String operations**: concatenation, interpolation
+* **Collections**: Lists, Maps
+* **Closures**: Functional operations
+* **Conditionals**: `if/else`, ternary operator
+
+```properties
+# Conditionals
+mode = production
+users = #{ props['mode'] == 'production' ? 100 : 10 }
+
+# String operations
+env.label = #{ "Environment: ${props['mode']}" }
+```
+
+## Security
+
+Groovy scripts run in a sandbox. The following operations are **blocked**:
+
+* File system access (`File`, `FileReader`, etc.)
+* Network operations (`URL`, `Socket`, etc.)
+* System access (`System`, `Runtime`)
+* Thread creation
+* Reflection
+
+Only safe imports are allowed: `java.util.*`, `java.math.*`, `java.text.*`
+
+## Error Handling
+
+Invalid Groovy expressions throw `IllegalArgumentException` with the script content and error message:
+
+```
+Failed to evaluate Groovy expression: #{invalid syntax} - ...
+```
+
+## Tips
+
+1. **Use `as int`** for integer results (Groovy defaults to `BigDecimal` for division)
+2. **Store complex calculations** in `ctx` to avoid repetition
+3. **Initialize early** - put setup expressions in properties that load first
+4. **Test expressions** - verify calculations produce expected values
diff --git a/doc/3rd-party-licenses/groovy/LICENSE.txt b/doc/3rd-party-licenses/groovy/LICENSE.txt
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/doc/3rd-party-licenses/groovy/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed 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
+
+ http://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.
diff --git a/doc/3rd-party-licenses/groovy/NOTICE.txt b/doc/3rd-party-licenses/groovy/NOTICE.txt
new file mode 100644
index 000000000..3b3e14394
--- /dev/null
+++ b/doc/3rd-party-licenses/groovy/NOTICE.txt
@@ -0,0 +1,5 @@
+Apache Groovy
+Copyright 2003-2025 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (https://www.apache.org/).
diff --git a/pom.xml b/pom.xml
index 1f9374236..6b07395c4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -619,6 +619,12 @@
reflections
0.10.2
+
+
+ org.apache.groovy
+ groovy
+ 5.0.4
+
diff --git a/src/main/java/com/xceptance/common/util/GroovyPropertyEvaluator.java b/src/main/java/com/xceptance/common/util/GroovyPropertyEvaluator.java
new file mode 100644
index 000000000..5e6610a74
--- /dev/null
+++ b/src/main/java/com/xceptance/common/util/GroovyPropertyEvaluator.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (c) 2005-2025 Xceptance Software Technologies GmbH
+ *
+ * Licensed 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
+ *
+ * http://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 com.xceptance.common.util;
+
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.codehaus.groovy.control.CompilerConfiguration;
+
+import groovy.lang.Binding;
+import groovy.lang.GroovyShell;
+import groovy.lang.Script;
+
+/**
+ * Utility class for evaluating Groovy expressions embedded in property values.
+ *
+ * Supports the Spring-like syntax {@code #{...}} for embedding Groovy code that will be evaluated at property
+ * resolution time. Multi-line scripts are supported.
+ *
+ *
+ * Scripts have access to:
+ *
+ * - {@code props} - Read-only access to property values
+ * - {@code ctx} - Shared Map for storing data between script evaluations
+ *
+ *
+ * Example usage in properties:
+ *
+ *
+ * totalUsers = 100
+ * browse.users = #{ (props['totalUsers'] as int) * 0.4 }
+ *
+ * # Multi-line with context sharing
+ * setup = #{
+ * ctx['base'] = props['totalUsers'] as int
+ * 'configured'
+ * }
+ * search.users = #{ ctx['base'] - 10 }
+ *
+ *
+ * @author Xceptance Software Technologies GmbH
+ * @since 8.0.0
+ */
+public class GroovyPropertyEvaluator
+{
+ /**
+ * Pattern to match Groovy expressions in the format #{...} Uses DOTALL flag to support multi-line scripts.
+ */
+ private static final Pattern GROOVY_PATTERN = Pattern.compile("#\\{(.+?)\\}", Pattern.DOTALL);
+
+ /**
+ * Cache for compiled scripts to improve performance. Thread-safe via ConcurrentHashMap.
+ */
+ private static final Map SCRIPT_CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * Shared GroovyShell with security customizations.
+ */
+ private static final GroovyShell SHELL;
+
+ static
+ {
+ final CompilerConfiguration config = new CompilerConfiguration();
+ config.addCompilationCustomizers(PropertyGroovySecurityUtils.createSecureCustomizer());
+ SHELL = new GroovyShell(config);
+ }
+
+ /**
+ * Private constructor to prevent instantiation.
+ */
+ private GroovyPropertyEvaluator()
+ {
+ }
+
+ /**
+ * Evaluates any Groovy expressions in the given value string.
+ *
+ * Groovy expressions are marked with {@code #{...}} syntax and can span multiple lines. The result of each expression
+ * replaces the entire {@code #{...}} block.
+ *
+ *
+ * @param value
+ * the string potentially containing Groovy expressions
+ * @param props
+ * properties available to scripts via 'props' binding (read-only)
+ * @param ctx
+ * shared context map available to scripts via 'ctx' binding
+ * @return the value with all Groovy expressions evaluated and replaced
+ * @throws IllegalArgumentException
+ * if a Groovy expression cannot be evaluated
+ */
+ public static String evaluateGroovyExpressions(final String value, final Properties props, final Map ctx)
+ {
+ if (value == null || value.isEmpty() || !value.contains("#{"))
+ {
+ return value;
+ }
+
+ final Matcher matcher = GROOVY_PATTERN.matcher(value);
+ final StringBuffer result = new StringBuffer();
+
+ while (matcher.find())
+ {
+ final String script = matcher.group(1).trim();
+ final String evaluated = evaluateSingleExpression(script, props, ctx);
+ matcher.appendReplacement(result, Matcher.quoteReplacement(evaluated));
+ }
+ matcher.appendTail(result);
+
+ return result.toString();
+ }
+
+ /**
+ * Evaluates a single Groovy script expression.
+ *
+ * @param script
+ * the Groovy script to evaluate
+ * @param props
+ * properties available via 'props' binding
+ * @param ctx
+ * shared context map available via 'ctx' binding
+ * @return the string representation of the script result
+ * @throws IllegalArgumentException
+ * if the script cannot be evaluated
+ */
+ private static String evaluateSingleExpression(final String script, final Properties props, final Map ctx)
+ {
+ try
+ {
+ // Create bindings for this evaluation
+ final Binding binding = new Binding();
+ binding.setVariable("props", new ReadOnlyProperties(props));
+ binding.setVariable("ctx", ctx);
+
+ // Get or compile the script
+ Script compiledScript = SCRIPT_CACHE.get(script);
+ if (compiledScript == null)
+ {
+ synchronized (SHELL)
+ {
+ compiledScript = SHELL.parse(script);
+ SCRIPT_CACHE.put(script, compiledScript);
+ }
+ }
+
+ // Execute with current bindings
+ final Script scriptInstance;
+ synchronized (compiledScript)
+ {
+ // Clone the script for thread safety
+ scriptInstance = compiledScript.getClass().getDeclaredConstructor().newInstance();
+ }
+ scriptInstance.setBinding(binding);
+
+ final Object result = scriptInstance.run();
+ return result == null ? "" : result.toString();
+ }
+ catch (final Exception e)
+ {
+ throw new IllegalArgumentException(String.format("Failed to evaluate Groovy expression: #{%s} - %s", script, e.getMessage()),
+ e);
+ }
+ }
+
+ /**
+ * Clears the script cache. Useful for testing or when properties change significantly.
+ */
+ public static void clearCache()
+ {
+ SCRIPT_CACHE.clear();
+ }
+
+ /**
+ * Read-only wrapper for Properties to prevent scripts from modifying properties.
+ */
+ private static class ReadOnlyProperties
+ {
+ private final Properties props;
+
+ public ReadOnlyProperties(final Properties props)
+ {
+ this.props = props;
+ }
+
+ /**
+ * Get a property value by key.
+ *
+ * @param key
+ * the property key
+ * @return the property value or null
+ */
+ public String getAt(final String key)
+ {
+ return props.getProperty(key);
+ }
+
+ /**
+ * Get a property value by key (alternative method name).
+ *
+ * @param key
+ * the property key
+ * @return the property value or null
+ */
+ public String getProperty(final String key)
+ {
+ return props.getProperty(key);
+ }
+
+ /**
+ * Get a property value with default.
+ *
+ * @param key
+ * the property key
+ * @param defaultValue
+ * the default value if key not found
+ * @return the property value or default
+ */
+ public String getProperty(final String key, final String defaultValue)
+ {
+ return props.getProperty(key, defaultValue);
+ }
+
+ /**
+ * Check if a property exists.
+ *
+ * @param key
+ * the property key
+ * @return true if the property exists
+ */
+ public boolean containsKey(final String key)
+ {
+ return props.containsKey(key);
+ }
+ }
+}
diff --git a/src/main/java/com/xceptance/common/util/PropertiesUtils.java b/src/main/java/com/xceptance/common/util/PropertiesUtils.java
index 353b2c758..7d53d8030 100644
--- a/src/main/java/com/xceptance/common/util/PropertiesUtils.java
+++ b/src/main/java/com/xceptance/common/util/PropertiesUtils.java
@@ -59,10 +59,10 @@ private PropertiesUtils()
* Loads the properties from the given file and returns them as a Properties object.
*
* @param file
- * the properties file
+ * the properties file
* @return the resulting Properties object
* @throws IOException
- * if an I/O error occurs
+ * if an I/O error occurs
*/
public static Properties loadProperties(final File file) throws IOException
{
@@ -75,11 +75,11 @@ public static Properties loadProperties(final File file) throws IOException
* Loads the properties from the given file and puts them into the specified Properties object.
*
* @param file
- * the properties file
+ * the properties file
* @param props
- * the properties object to load the properties into
+ * the properties object to load the properties into
* @throws IOException
- * if an I/O error occurs
+ * if an I/O error occurs
*/
public static void loadProperties(final File file, final Properties props) throws IOException
{
@@ -93,10 +93,10 @@ public static void loadProperties(final File file, final Properties props) throw
* Loads the properties from the given file and returns them as a Properties object.
*
* @param file
- * the properties file
+ * the properties file
* @return the resulting Properties object
* @throws IOException
- * if an I/O error occurs
+ * if an I/O error occurs
*/
public static Properties loadProperties(final FileObject file) throws IOException
{
@@ -111,11 +111,11 @@ public static Properties loadProperties(final FileObject file) throws IOExceptio
* Loads the properties from the given file and puts them into the specified Properties object.
*
* @param file
- * the properties file
+ * the properties file
* @param props
- * the properties object to load the properties into
+ * the properties object to load the properties into
* @throws IOException
- * if an I/O error occurs
+ * if an I/O error occurs
*/
public static void loadProperties(final FileObject file, final Properties props) throws IOException
{
@@ -123,7 +123,7 @@ public static void loadProperties(final FileObject file, final Properties props)
{
ParameterCheckUtils.isReadableFile(file, "file");
}
- catch(IllegalArgumentException e)
+ catch (IllegalArgumentException e)
{
throw new FileNotFoundException(file.toString());
}
@@ -136,8 +136,7 @@ public static void loadProperties(final FileObject file, final Properties props)
}
/**
- * Perform variable substitution in string value from the values of keys found in the system
- * properties.
+ * Perform variable substitution in string value from the values of keys found in the system properties.
*
* The variable substitution delimiters are ${ and }.
*
@@ -160,43 +159,88 @@ public static void loadProperties(final FileObject file, final Properties props)
*
* will set s to "Value of nonexistentKey is []"
*
- * An {@link java.lang.IllegalArgumentException} is thrown if value contains a start delimiter "${"
- * which is not balanced by a stop delimiter "}".
+ * Additionally, Groovy expressions in the format #{...} are evaluated after variable substitution. Multi-line
+ * scripts are supported.
+ *
+ *
+ * An {@link java.lang.IllegalArgumentException} is thrown if value contains a start delimiter "${" which
+ * is not balanced by a stop delimiter "}".
*
*
* @param value
- * the string on which variable substitution is performed
+ * the string on which variable substitution is performed
* @param properties
- * properties object to be used for variable lookup
+ * properties object to be used for variable lookup
* @return argument string where variable have been substituted
* @throws IllegalArgumentException
- * if value is malformed
+ * if value is malformed
*/
public static String substituteVariables(final String value, final Properties properties) throws IllegalArgumentException
+ {
+ return substituteVariables(value, properties, null);
+ }
+
+ /**
+ * Perform variable substitution in string value from the values of keys found in the system properties,
+ * with support for Groovy expressions and shared context.
+ *
+ * Variable substitution uses ${...} syntax. Groovy expressions use #{...} syntax and are evaluated after
+ * variable substitution. Groovy scripts can access:
+ *
+ * props - read-only access to property values
+ * ctx - shared Map for storing data between script evaluations
+ *
+ *
+ *
+ * @param value
+ * the string on which variable substitution is performed
+ * @param properties
+ * properties object to be used for variable lookup
+ * @param ctx
+ * shared context map for Groovy scripts (may be null, in which case an empty map is used)
+ * @return argument string where variables and Groovy expressions have been substituted
+ * @throws IllegalArgumentException
+ * if value is malformed or Groovy evaluation fails
+ * @since 8.0.0
+ */
+ public static String substituteVariables(final String value, final Properties properties, final Map ctx)
+ throws IllegalArgumentException
{
// parameter validation
ParameterCheckUtils.isNotNull(value, "value");
ParameterCheckUtils.isNotNull(properties, "props");
- if (value.length() == 0 || properties.size() == 0)
+ if (value.isEmpty())
{
return value;
}
- // recursively resolve the variables in the value
- return resolveVariables(value, properties, new HashSet());
+ // Step 1: Resolve ${...} variable references
+ String result = value;
+ if (properties.size() > 0)
+ {
+ result = resolveVariables(value, properties, new HashSet());
+ }
+
+ // Step 2: Evaluate #{...} Groovy expressions
+ if (result.contains("#{"))
+ {
+ final Map contextMap = ctx != null ? ctx : new java.util.concurrent.ConcurrentHashMap<>();
+ result = GroovyPropertyEvaluator.evaluateGroovyExpressions(result, properties, contextMap);
+ }
+
+ return result;
}
/**
- * Resolves the given value as variable reference using the given properties object and set of already known
- * variables.
+ * Resolves the given value as variable reference using the given properties object and set of already known variables.
*
* @param value
- * variable reference to be resolved
+ * variable reference to be resolved
* @param props
- * properties object to be used to resolve the variable reference
+ * properties object to be used to resolve the variable reference
* @param variables
- * set of already known variables in current lookup path
+ * set of already known variables in current lookup path
* @return resolved variable reference
*/
private static String resolveVariables(final String value, final Properties props, final Set variables)
@@ -249,15 +293,14 @@ private static String resolveVariables(final String value, final Properties prop
}
/**
- * Returns all properties for this domain key, strips the key from the property name, e.g.
- * ClassName.Testproperty=ABC --> TestProperty=ABC Attention: Properties without a domain (e.g. foobar=test) or
- * domain only properties are invalid and will be ignored. A property has to have at least this form:
- * domain.propertyname=value
+ * Returns all properties for this domain key, strips the key from the property name, e.g. ClassName.Testproperty=ABC
+ * --> TestProperty=ABC Attention: Properties without a domain (e.g. foobar=test) or domain only properties are invalid
+ * and will be ignored. A property has to have at least this form: domain.propertyname=value
*
* @param domainKey
- * domain for the properties
+ * domain for the properties
* @param properties
- * the properties from which to return the matching entries
+ * the properties from which to return the matching entries
* @return map with all key value pairs of properties
*/
public static Map getPropertiesForKey(final String domainKey, final Properties properties)
diff --git a/src/main/java/com/xceptance/common/util/PropertyGroovySecurityUtils.java b/src/main/java/com/xceptance/common/util/PropertyGroovySecurityUtils.java
new file mode 100644
index 000000000..5a529651a
--- /dev/null
+++ b/src/main/java/com/xceptance/common/util/PropertyGroovySecurityUtils.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2005-2025 Xceptance Software Technologies GmbH
+ *
+ * Licensed 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
+ *
+ * http://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 com.xceptance.common.util;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.codehaus.groovy.control.customizers.SecureASTCustomizer;
+
+/**
+ * Security configuration for Groovy scripts executed in property evaluation.
+ *
+ * This customizer restricts the capabilities of Groovy scripts to prevent potentially dangerous operations such as file
+ * system access, network operations, or system command execution.
+ *
+ *
+ * Allowed operations:
+ *
+ * - Basic arithmetic and string operations
+ * - java.util collections (List, Map, etc.)
+ * - java.math (BigDecimal, BigInteger)
+ * - java.text formatting
+ * - Closures for functional operations
+ *
+ *
+ *
+ * Blocked operations:
+ *
+ * - File system access (java.io, java.nio.file)
+ * - Network operations (java.net)
+ * - Process execution (Runtime, ProcessBuilder)
+ * - Reflection operations
+ * - Thread creation
+ * - System property modification
+ *
+ *
+ *
+ * @author Xceptance Software Technologies GmbH
+ * @since 8.0.0
+ */
+public class PropertyGroovySecurityUtils
+{
+ /**
+ * Private constructor to prevent instantiation.
+ */
+ private PropertyGroovySecurityUtils()
+ {
+ }
+
+ /**
+ * Creates a secure AST customizer for property Groovy evaluation.
+ *
+ * Allows safe imports for mathematical and collection operations while blocking dangerous classes and operations.
+ *
+ *
+ * @return configured SecureASTCustomizer
+ */
+ public static SecureASTCustomizer createSecureCustomizer()
+ {
+ final SecureASTCustomizer secure = new SecureASTCustomizer();
+
+ // Allow closures for functional calculations
+ secure.setClosuresAllowed(true);
+
+ // Whitelist safe package imports (explicit star imports only)
+ final List allowedStarImports = Arrays.asList("java.util", "java.math", "java.text");
+ secure.setStarImportsWhitelist(allowedStarImports);
+ secure.setIndirectImportCheckEnabled(true);
+
+ // Block direct class references to dangerous classes
+ // This prevents using fully-qualified names like java.io.File
+ secure.setReceiversClassesBlackList(Arrays.asList(
+ // System and runtime
+ System.class, Runtime.class, ProcessBuilder.class, Thread.class,
+ ClassLoader.class,
+ // File I/O
+ java.io.File.class, java.io.FileReader.class, java.io.FileWriter.class,
+ java.io.FileInputStream.class, java.io.FileOutputStream.class,
+ java.io.RandomAccessFile.class,
+ // Network
+ java.net.URL.class, java.net.URI.class, java.net.Socket.class,
+ java.net.ServerSocket.class, java.net.HttpURLConnection.class));
+
+ return secure;
+ }
+}
diff --git a/src/test/java/com/xceptance/common/util/PropertiesUtilsTest.java b/src/test/java/com/xceptance/common/util/PropertiesUtilsTest.java
index 3645aced5..c3abc0a8a 100644
--- a/src/test/java/com/xceptance/common/util/PropertiesUtilsTest.java
+++ b/src/test/java/com/xceptance/common/util/PropertiesUtilsTest.java
@@ -55,14 +55,14 @@ public void init()
public void testSubstituteVars()
{
// string containing variables for substitution
- final String testString = "The key testKey should be set to ${testKey}. " + "And this is key is empty: [${emptyKey}]. "
- + "Additionally, here we have a key containing a special character: ${$foo-bar}."
- + "Last but not least, an undefined key: #${undefined}#";
+ final String testString = "The key testKey should be set to ${testKey}. " + "And this is key is empty: [${emptyKey}]. " +
+ "Additionally, here we have a key containing a special character: ${$foo-bar}." +
+ "Last but not least, an undefined key: #${undefined}#";
// expected result string as indicated in documentation of
// 'substituteVariables'
- final String replacedString = "The key testKey should be set to testValue. " + "And this is key is empty: []. "
- + "Additionally, here we have a key containing a special character: jesus."
- + "Last but not least, an undefined key: #${undefined}#";
+ final String replacedString = "The key testKey should be set to testValue. " + "And this is key is empty: []. " +
+ "Additionally, here we have a key containing a special character: jesus." +
+ "Last but not least, an undefined key: #${undefined}#";
// result of calling 'substituteVariables'
final String resultString = PropertiesUtils.substituteVariables(testString, props);
@@ -139,8 +139,8 @@ public void testLoadProperties_ValidFile() throws Exception
}
/**
- * Tests the implementation of {@link PropertiesUtils#loadProperties(File)} by passing a valid file and valid
- * properties as parameters.
+ * Tests the implementation of {@link PropertiesUtils#loadProperties(File)} by passing a valid file and valid properties
+ * as parameters.
*/
@Test
public void testLoadProperties_ValidFileValidProperties() throws Exception
@@ -150,4 +150,147 @@ public void testLoadProperties_ValidFileValidProperties() throws Exception
Assert.assertTrue(props.containsKey("testKey"));
Assert.assertTrue(props.containsKey("emptyKey"));
}
+
+ // =========================================================================
+ // Groovy Expression Tests
+ // =========================================================================
+
+ /**
+ * Test basic Groovy arithmetic expressions.
+ */
+ @Test
+ public void testGroovyBasicArithmetic()
+ {
+ Assert.assertEquals("2", PropertiesUtils.substituteVariables("#{ 1 + 1 }", props));
+ Assert.assertEquals("40.0", PropertiesUtils.substituteVariables("#{ 100 * 0.4 }", props));
+ Assert.assertEquals("25", PropertiesUtils.substituteVariables("#{ 100 / 4 as int }", props));
+ }
+
+ /**
+ * Test Groovy property access using props binding.
+ */
+ @Test
+ public void testGroovyPropertyAccess()
+ {
+ props.setProperty("userCount", "100");
+
+ // Using getAt syntax (props['key'])
+ Assert.assertEquals("100", PropertiesUtils.substituteVariables("#{ props['userCount'] }", props));
+
+ // Using getProperty method
+ Assert.assertEquals("100", PropertiesUtils.substituteVariables("#{ props.getProperty('userCount') }", props));
+
+ // Calculation with property
+ Assert.assertEquals("40", PropertiesUtils.substituteVariables("#{ (props['userCount'] as int) * 0.4 as int }", props));
+ }
+
+ /**
+ * Test Groovy context sharing between expressions.
+ */
+ @Test
+ public void testGroovyContextSharing()
+ {
+ final java.util.Map ctx = new java.util.concurrent.ConcurrentHashMap<>();
+
+ // Store value in context
+ String result1 = PropertiesUtils.substituteVariables("#{ ctx['myKey'] = 42; 'stored' }", props, ctx);
+ Assert.assertEquals("stored", result1);
+
+ // Retrieve value from context
+ String result2 = PropertiesUtils.substituteVariables("#{ ctx['myKey'] }", props, ctx);
+ Assert.assertEquals("42", result2);
+
+ // Calculate based on stored context
+ String result3 = PropertiesUtils.substituteVariables("#{ ctx['myKey'] * 2 }", props, ctx);
+ Assert.assertEquals("84", result3);
+ }
+
+ /**
+ * Test multi-line Groovy scripts.
+ */
+ @Test
+ public void testGroovyMultiLineScript()
+ {
+ props.setProperty("totalUsers", "100");
+
+ final java.util.Map ctx = new java.util.concurrent.ConcurrentHashMap<>();
+
+ String script = """
+ #{
+ def total = props['totalUsers'] as int
+ ctx['base'] = total
+ ctx['browse'] = (total * 0.4) as int
+ 'configured'
+ }""";
+
+ String result = PropertiesUtils.substituteVariables(script, props, ctx);
+ Assert.assertEquals("configured", result);
+ Assert.assertEquals(100, ctx.get("base"));
+ Assert.assertEquals(40, ctx.get("browse"));
+
+ // Now use the stored values
+ String users = PropertiesUtils.substituteVariables("#{ ctx['base'] - ctx['browse'] }", props, ctx);
+ Assert.assertEquals("60", users);
+ }
+
+ /**
+ * Test mixed ${} and #{} expansion.
+ */
+ @Test
+ public void testGroovyMixedExpansion()
+ {
+ props.setProperty("base", "100");
+
+ // ${base} is resolved first, then #{} evaluates the result
+ Assert.assertEquals("150", PropertiesUtils.substituteVariables("#{ ${base} + 50 }", props));
+ }
+
+ /**
+ * Test Groovy expression embedded in text.
+ */
+ @Test
+ public void testGroovyEmbeddedInText()
+ {
+ props.setProperty("count", "5");
+
+ String result = PropertiesUtils.substituteVariables("Users: #{ props['count'] as int * 10 }", props);
+ Assert.assertEquals("Users: 50", result);
+ }
+
+ /**
+ * Test that no Groovy markers returns value unchanged.
+ */
+ @Test
+ public void testGroovyNoMarkers()
+ {
+ Assert.assertEquals("plain text", PropertiesUtils.substituteVariables("plain text", props));
+ Assert.assertEquals("${testKey}", PropertiesUtils.substituteVariables("${testKey}", new Properties()));
+ }
+
+ /**
+ * Test that invalid Groovy syntax throws exception.
+ */
+ @Test(expected = IllegalArgumentException.class)
+ public void testGroovyInvalidSyntax()
+ {
+ PropertiesUtils.substituteVariables("#{ this is not valid groovy ++ }", props);
+ }
+
+ /**
+ * Test that security blocks dangerous operations.
+ */
+ @Test(expected = IllegalArgumentException.class)
+ public void testGroovySecurityBlocksFileAccess()
+ {
+ PropertiesUtils.substituteVariables("#{ new java.io.File('/etc/passwd') }", props);
+ }
+
+ /**
+ * Test null result from Groovy returns empty string.
+ */
+ @Test
+ public void testGroovyNullResult()
+ {
+ Assert.assertEquals("", PropertiesUtils.substituteVariables("#{ null }", props));
+ }
}
From 0acc9b8278d1148a3b3f743c0649292ece29b08e Mon Sep 17 00:00:00 2001
From: Rene Schwietzke
Date: Mon, 2 Feb 2026 21:32:24 +0100
Subject: [PATCH 2/4] Removing file with dev remarks
---
FOR_RELEASE.md | 2 --
1 file changed, 2 deletions(-)
delete mode 100644 FOR_RELEASE.md
diff --git a/FOR_RELEASE.md b/FOR_RELEASE.md
deleted file mode 100644
index 0091f232c..000000000
--- a/FOR_RELEASE.md
+++ /dev/null
@@ -1,2 +0,0 @@
-# Properties
-* still accepting properties to be included outside of config (aka home or subdirs), but these won't be copied as part of the results anymore, if you want them preserved, they have to live in the config dir
\ No newline at end of file
From 1e17473158395e289f0babdd4d760996812f3b7e Mon Sep 17 00:00:00 2001
From: Rene Schwietzke
Date: Mon, 2 Feb 2026 21:33:00 +0100
Subject: [PATCH 3/4] Added .vscode to ignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 1152df27c..e3e6bffa7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
/resultbrowser/node_modules/
/resultbrowser/dist/
/resultbrowser/.parcel-cache
+.vscode/
From 7c419a2bb18a5bd7ab78ac5c730a6acc38378765 Mon Sep 17 00:00:00 2001
From: Rene Schwietzke
Date: Mon, 2 Feb 2026 22:18:23 +0100
Subject: [PATCH 4/4] Clarified multi-line properites writing style to make it
clear that the property continues on the next line
---
PROPERTY_EXPANSION.md | 54 ++++++++++++++++++++++---------------------
1 file changed, 28 insertions(+), 26 deletions(-)
diff --git a/PROPERTY_EXPANSION.md b/PROPERTY_EXPANSION.md
index 6d751dbd4..d88fce3b7 100644
--- a/PROPERTY_EXPANSION.md
+++ b/PROPERTY_EXPANSION.md
@@ -67,42 +67,44 @@ browseUsers = #{ ${totalUsers} * 0.4 }
browseUsers = #{ props['totalUsers'] as int * 0.4 }
```
-### `ctx` - Shared Context Map
+## Multi-Line Scripts
-Store and share values between expressions:
+Groovy expressions can span multiple lines:
```properties
-# Store computed values
-init = #{
- ctx['base'] = props['totalUsers'] as int
- ctx['loaded'] = true
- 'initialized'
+com.xceptance.xlt.loadtests.config = #{ \
+ def total = props['totalUsers'] as int \
+ def browse = (total * 0.4) as int \
+ def order = (total * 0.1) as int \
+ \
+ ctx['totalUsers'] = total \
+ ctx['browseUsers'] = browse \
+ ctx['orderUsers'] = order \
+ \
+ 'configured' \
}
-# Use stored values later
-browse.users = #{ ctx['base'] * 0.4 }
-order.users = #{ ctx['base'] * 0.1 }
+com.xceptance.xlt.loadtests.TBrowse.users = #{ ctx['browseUsers'] }
+com.xceptance.xlt.loadtests.TOrder.users = #{ ctx['orderUsers'] }
```
-## Multi-Line Scripts
+> **Note**: Java properties do not support multi-line text directly. If you need to use multi-line scripts, you must use the `\` line ending to tell the properties file parser that the data continues on the next line.
-Groovy expressions can span multiple lines:
+### `ctx` - Shared Context Map
+
+Store and share values between expressions:
```properties
-com.xceptance.xlt.loadtests.config = #{
- def total = props['totalUsers'] as int
- def browse = (total * 0.4) as int
- def order = (total * 0.1) as int
-
- ctx['totalUsers'] = total
- ctx['browseUsers'] = browse
- ctx['orderUsers'] = order
-
- 'configured'
+# Store computed values
+init = #{ \
+ ctx['base'] = props['totalUsers'] as int \
+ ctx['loaded'] = true \
+ 'initialized' \
}
-com.xceptance.xlt.loadtests.TBrowse.users = #{ ctx['browseUsers'] }
-com.xceptance.xlt.loadtests.TOrder.users = #{ ctx['orderUsers'] }
+# Use stored values later
+browse.users = #{ ctx['base'] * 0.4 }
+order.users = #{ ctx['base'] * 0.1 }
```
## Practical Examples
@@ -138,8 +140,8 @@ hourlyRequests = 10000
testDurationHours = 1
# Calculate arrival rate per hour
-com.xceptance.xlt.loadtests.TSearch.arrivalRate = #{
- props['hourlyRequests'] as int
+com.xceptance.xlt.loadtests.TSearch.arrivalRate = #{ \
+ props['hourlyRequests'] as int \
}
```