Skip to content

Commit 6abb41d

Browse files
committed
fix: Resolve environment variable substitution for mixed quotes
Fixes #7429 - Environment variable substitution no longer breaks parsing of YAML values containing both single and double quotes. Values that are not intended for substitution are now parsed correctly as literal strings.
1 parent 3f73f12 commit 6abb41d

File tree

2 files changed

+131
-80
lines changed

2 files changed

+131
-80
lines changed

sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfiguration.java

Lines changed: 97 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,25 @@
2222
import java.io.IOException;
2323
import java.io.InputStream;
2424
import java.util.Collections;
25-
import java.util.List;
2625
import java.util.Map;
26+
import java.util.Objects;
2727
import java.util.logging.Logger;
2828
import java.util.regex.MatchResult;
2929
import java.util.regex.Matcher;
3030
import java.util.regex.Pattern;
3131
import javax.annotation.Nullable;
3232
import org.snakeyaml.engine.v2.api.Load;
3333
import org.snakeyaml.engine.v2.api.LoadSettings;
34+
import org.snakeyaml.engine.v2.api.YamlUnicodeReader;
3435
import org.snakeyaml.engine.v2.common.ScalarStyle;
35-
import org.snakeyaml.engine.v2.constructor.StandardConstructor;
36-
import org.snakeyaml.engine.v2.exceptions.ConstructorException;
37-
import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
36+
import org.snakeyaml.engine.v2.composer.Composer;
3837
import org.snakeyaml.engine.v2.nodes.MappingNode;
3938
import org.snakeyaml.engine.v2.nodes.Node;
40-
import org.snakeyaml.engine.v2.nodes.NodeTuple;
4139
import org.snakeyaml.engine.v2.nodes.ScalarNode;
40+
import org.snakeyaml.engine.v2.nodes.Tag;
41+
import org.snakeyaml.engine.v2.parser.ParserImpl;
42+
import org.snakeyaml.engine.v2.resolver.ScalarResolver;
43+
import org.snakeyaml.engine.v2.scanner.StreamReader;
4244
import org.snakeyaml.engine.v2.schema.CoreSchema;
4345

4446
/**
@@ -126,8 +128,9 @@ public static OpenTelemetrySdk create(
126128
/**
127129
* Parse the {@code configuration} YAML and return the {@link OpenTelemetryConfigurationModel}.
128130
*
129-
* <p>Before parsing, environment variable substitution is performed as described in {@link
130-
* EnvSubstitutionConstructor}.
131+
* <p>During parsing, environment variable substitution is performed as defined in the <a
132+
* href="https://opentelemetry.io/docs/specs/otel/configuration/data-model/#environment-variable-substitution">
133+
* OpenTelemetry Configuration Data Model specification</a>.
131134
*
132135
* @throws DeclarativeConfigException if unable to parse
133136
*/
@@ -149,7 +152,7 @@ static OpenTelemetryConfigurationModel parse(
149152
// Visible for testing
150153
static Object loadYaml(InputStream inputStream, Map<String, String> environmentVariables) {
151154
LoadSettings settings = LoadSettings.builder().setSchema(new CoreSchema()).build();
152-
Load yaml = new Load(settings, new EnvSubstitutionConstructor(settings, environmentVariables));
155+
Load yaml = new EnvLoad(settings, environmentVariables);
153156
return yaml.loadFromInputStream(inputStream);
154157
}
155158

@@ -241,89 +244,111 @@ static <M, R> R createAndMaybeCleanup(Factory<M, R> factory, SpiHelper spiHelper
241244
}
242245
}
243246

247+
private static final class EnvLoad extends Load {
248+
249+
private final LoadSettings settings;
250+
private final Map<String, String> environmentVariables;
251+
252+
public EnvLoad(LoadSettings settings, Map<String, String> environmentVariables) {
253+
super(settings);
254+
this.settings = settings;
255+
this.environmentVariables = environmentVariables;
256+
}
257+
258+
@Override
259+
public Object loadFromInputStream(InputStream yamlStream) {
260+
Objects.requireNonNull(yamlStream, "InputStream cannot be null");
261+
return loadOne(
262+
new EnvComposer(
263+
settings,
264+
new ParserImpl(
265+
settings, new StreamReader(settings, new YamlUnicodeReader(yamlStream))),
266+
environmentVariables));
267+
}
268+
}
269+
244270
/**
245-
* {@link StandardConstructor} which substitutes environment variables.
271+
* A YAML Composer that performs environment variable substitution according to the <a
272+
* href="https://opentelemetry.io/docs/specs/otel/configuration/data-model/#environment-variable-substitution">
273+
* OpenTelemetry Configuration Data Model specification</a>.
274+
*
275+
* <p>This composer supports:
246276
*
247-
* <p>Environment variables follow the syntax {@code ${VARIABLE}}, where {@code VARIABLE} is an
248-
* environment variable matching the regular expression {@code [a-zA-Z_]+[a-zA-Z0-9_]*}.
277+
* <ul>
278+
* <li>Environment variable references: {@code ${ENV_VAR}} or {@code ${env:ENV_VAR}}
279+
* <li>Default values: {@code ${ENV_VAR:-default_value}}
280+
* <li>Escape sequences: {@code $$} is replaced with a single {@code $}
281+
* </ul>
249282
*
250-
* <p>Environment variable substitution only takes place on scalar values of maps. References to
251-
* environment variables in keys or sets are ignored.
283+
* <p>Environment variable substitution only applies to scalar values. Mapping keys are not
284+
* candidates for substitution. Referenced environment variables that are undefined, null, or
285+
* empty are replaced with empty values unless a default value is provided.
252286
*
253-
* <p>If a referenced environment variable is not defined, it is replaced with {@code ""}.
287+
* <p>The {@code $} character serves as an escape sequence where {@code $$} in the input is
288+
* translated to a single {@code $} in the output. This prevents environment variable substitution
289+
* for the escaped content.
254290
*/
255-
private static final class EnvSubstitutionConstructor extends StandardConstructor {
291+
private static final class EnvComposer extends Composer {
256292

257-
// Load is not thread safe but this instance is always used on the same thread
258293
private final Load load;
259294
private final Map<String, String> environmentVariables;
295+
private final ScalarResolver scalarResolver;
296+
297+
private static final String ESCAPE_SEQUENCE = "$$";
298+
private static final int ESCAPE_SEQUENCE_LENGTH = ESCAPE_SEQUENCE.length();
299+
private static final char ESCAPE_SEQUENCE_REPLACEMENT = '$';
260300

261-
private EnvSubstitutionConstructor(
262-
LoadSettings loadSettings, Map<String, String> environmentVariables) {
263-
super(loadSettings);
264-
load = new Load(loadSettings);
301+
public EnvComposer(
302+
LoadSettings settings, ParserImpl parser, Map<String, String> environmentVariables) {
303+
super(settings, parser);
304+
this.load = new Load(settings);
265305
this.environmentVariables = environmentVariables;
306+
this.scalarResolver = settings.getSchema().getScalarResolver();
266307
}
267308

268-
/**
269-
* Implementation is same as {@link
270-
* org.snakeyaml.engine.v2.constructor.BaseConstructor#constructMapping(MappingNode)} except we
271-
* override the resolution of values with our custom {@link #constructValueObject(Node)}, which
272-
* performs environment variable substitution.
273-
*/
274309
@Override
275-
@SuppressWarnings({"ReturnValueIgnored", "CatchingUnchecked"})
276-
protected Map<Object, Object> constructMapping(MappingNode node) {
277-
Map<Object, Object> mapping = settings.getDefaultMap().apply(node.getValue().size());
278-
List<NodeTuple> nodeValue = node.getValue();
279-
for (NodeTuple tuple : nodeValue) {
280-
Node keyNode = tuple.getKeyNode();
281-
Object key = constructObject(keyNode);
282-
if (key != null) {
283-
try {
284-
key.hashCode(); // check circular dependencies
285-
} catch (Exception e) {
286-
throw new ConstructorException(
287-
"while constructing a mapping",
288-
node.getStartMark(),
289-
"found unacceptable key " + key,
290-
tuple.getKeyNode().getStartMark(),
291-
e);
292-
}
293-
}
294-
Node valueNode = tuple.getValueNode();
295-
Object value = constructValueObject(valueNode);
296-
if (keyNode.isRecursive()) {
297-
if (settings.getAllowRecursiveKeys()) {
298-
postponeMapFilling(mapping, key, value);
299-
} else {
300-
throw new YamlEngineException(
301-
"Recursive key for mapping is detected but it is not configured to be allowed.");
302-
}
303-
} else {
304-
mapping.put(key, value);
305-
}
310+
protected Node composeValueNode(MappingNode node) {
311+
Node itemValue = super.composeValueNode(node);
312+
if (!(itemValue instanceof ScalarNode)) {
313+
// Only apply environment variable substitution to ScalarNodes
314+
return itemValue;
306315
}
316+
ScalarNode scalarNode = (ScalarNode) itemValue;
317+
String envSubstitution = envSubstitution(scalarNode.getValue());
307318

308-
return mapping;
309-
}
319+
// If the environment variable substitution does not change the value, do not modify the node
320+
if (envSubstitution.equals(scalarNode.getValue())) {
321+
return itemValue;
322+
}
310323

311-
private static final String ESCAPE_SEQUENCE = "$$";
312-
private static final int ESCAPE_SEQUENCE_LENGTH = ESCAPE_SEQUENCE.length();
313-
private static final char ESCAPE_SEQUENCE_REPLACEMENT = '$';
324+
Object envSubstitutionObj = load.loadFromString(envSubstitution);
325+
Tag tag = itemValue.getTag();
326+
ScalarStyle scalarStyle = scalarNode.getScalarStyle();
314327

315-
private Object constructValueObject(Node node) {
316-
Object value = constructObject(node);
317-
if (!(node instanceof ScalarNode)) {
318-
return value;
319-
}
320-
if (!(value instanceof String)) {
321-
return value;
328+
Tag resolvedTag =
329+
envSubstitutionObj == null
330+
? Tag.NULL
331+
: scalarResolver.resolve(envSubstitutionObj.toString(), true);
332+
333+
// Only non-quoted substituted scalars can have their tag changed
334+
if (!itemValue.getTag().equals(resolvedTag)) {
335+
if (!scalarStyle.equals(ScalarStyle.SINGLE_QUOTED)
336+
&& !scalarStyle.equals(ScalarStyle.DOUBLE_QUOTED)) {
337+
tag = resolvedTag;
338+
}
322339
}
323340

324-
String val = (String) value;
325-
ScalarStyle scalarStyle = ((ScalarNode) node).getScalarStyle();
341+
boolean resolved = true;
342+
return new ScalarNode(
343+
tag,
344+
resolved,
345+
envSubstitution,
346+
scalarStyle,
347+
itemValue.getStartMark(),
348+
itemValue.getEndMark());
349+
}
326350

351+
private String envSubstitution(String val) {
327352
// Iterate through val left to right, search for escape sequence "$$"
328353
// For the substring of val between the last escape sequence and the next found, perform
329354
// environment variable substitution
@@ -346,13 +371,7 @@ private Object constructValueObject(Node node) {
346371
}
347372
}
348373

349-
// If the value was double quoted, retain the double quotes so we don't change a value
350-
// intended to be a string to a different type after environment variable substitution
351-
if (scalarStyle == ScalarStyle.DOUBLE_QUOTED) {
352-
newVal.insert(0, "\"");
353-
newVal.append("\"");
354-
}
355-
return load.loadFromString(newVal.toString());
374+
return newVal.toString();
356375
}
357376

358377
private StringBuilder envVarSubstitution(

sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
import java.util.HashMap;
9393
import java.util.Map;
9494
import javax.annotation.Nullable;
95+
import org.junit.jupiter.api.Assertions;
9596
import org.junit.jupiter.api.Test;
9697
import org.junit.jupiter.params.ParameterizedTest;
9798
import org.junit.jupiter.params.provider.Arguments;
@@ -855,6 +856,27 @@ void parse_nullBoxedPrimitivesParsedToNull() {
855856
.withTraceIdRatioBased(new TraceIdRatioBasedSamplerModel()))));
856857
}
857858

859+
@Test
860+
void parse_quotedInput() {
861+
String yaml =
862+
"resource:\n"
863+
+ " attributes:\n"
864+
+ " - name: single_quote\n"
865+
+ " value: '\"single\"'\n"
866+
+ " - name: double_quote\n"
867+
+ " value: \"\\\"double\\\"\"";
868+
869+
OpenTelemetryConfigurationModel model =
870+
DeclarativeConfiguration.parse(
871+
new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)));
872+
873+
Assertions.assertNotNull(model.getResource());
874+
assertThat(model.getResource().getAttributes())
875+
.containsExactly(
876+
new AttributeNameValueModel().withName("single_quote").withValue("\"single\""),
877+
new AttributeNameValueModel().withName("double_quote").withValue("\"double\""));
878+
}
879+
858880
@ParameterizedTest
859881
@MethodSource("coreSchemaValuesArgs")
860882
void coreSchemaValues(String rawYaml, Object expectedYamlResult) {
@@ -928,7 +950,7 @@ private static java.util.stream.Stream<Arguments> envVarSubstitutionArgs() {
928950
// Undefined / empty environment variable
929951
Arguments.of("key1: ${EMPTY_STR}\n", mapOf(entry("key1", null))),
930952
Arguments.of("key1: ${STR_3}\n", mapOf(entry("key1", null))),
931-
Arguments.of("key1: ${STR_1} ${STR_3}\n", mapOf(entry("key1", "value1"))),
953+
Arguments.of("key1: ${STR_1} ${STR_3}\n", mapOf(entry("key1", "value1 "))),
932954
// Environment variable keys must match pattern: [a-zA-Z_]+[a-zA-Z0-9_]*
933955
Arguments.of("key1: ${VAR&}\n", mapOf(entry("key1", "${VAR&}"))),
934956
// Environment variable substitution only takes place in scalar values of maps
@@ -938,13 +960,23 @@ private static java.util.stream.Stream<Arguments> envVarSubstitutionArgs() {
938960
mapOf(entry("key1", mapOf(entry("${STR_1}", "value1"))))),
939961
Arguments.of(
940962
"key1:\n - ${STR_1}\n", mapOf(entry("key1", Collections.singletonList("${STR_1}")))),
941-
// Quoted environment variables
963+
// Double-quoted environment variables
942964
Arguments.of("key1: \"${HEX}\"\n", mapOf(entry("key1", "0xdeadbeef"))),
943965
Arguments.of("key1: \"${STR_1}\"\n", mapOf(entry("key1", "value1"))),
944966
Arguments.of("key1: \"${EMPTY_STR}\"\n", mapOf(entry("key1", ""))),
945967
Arguments.of("key1: \"${BOOL}\"\n", mapOf(entry("key1", "true"))),
946968
Arguments.of("key1: \"${INT}\"\n", mapOf(entry("key1", "1"))),
947969
Arguments.of("key1: \"${FLOAT}\"\n", mapOf(entry("key1", "1.1"))),
970+
Arguments.of(
971+
"key1: \"${HEX} ${BOOL} ${INT}\"\n", mapOf(entry("key1", "0xdeadbeef true 1"))),
972+
// Single-quoted environment variables
973+
Arguments.of("key1: '${HEX}'\n", mapOf(entry("key1", "0xdeadbeef"))),
974+
Arguments.of("key1: '${STR_1}'\n", mapOf(entry("key1", "value1"))),
975+
Arguments.of("key1: '${EMPTY_STR}'\n", mapOf(entry("key1", ""))),
976+
Arguments.of("key1: '${BOOL}'\n", mapOf(entry("key1", "true"))),
977+
Arguments.of("key1: '${INT}'\n", mapOf(entry("key1", "1"))),
978+
Arguments.of("key1: '${FLOAT}'\n", mapOf(entry("key1", "1.1"))),
979+
Arguments.of("key1: '${HEX} ${BOOL} ${INT}'\n", mapOf(entry("key1", "0xdeadbeef true 1"))),
948980
// Escaped
949981
Arguments.of("key1: ${FOO}\n", mapOf(entry("key1", "BAR"))),
950982
Arguments.of("key1: $${FOO}\n", mapOf(entry("key1", "${FOO}"))),

0 commit comments

Comments
 (0)