Skip to content

Commit 8891732

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 8891732

File tree

2 files changed

+114
-80
lines changed

2 files changed

+114
-80
lines changed

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

Lines changed: 88 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,25 @@
2424
import java.util.Collections;
2525
import java.util.List;
2626
import java.util.Map;
27+
import java.util.Objects;
2728
import java.util.logging.Logger;
2829
import java.util.regex.MatchResult;
2930
import java.util.regex.Matcher;
3031
import java.util.regex.Pattern;
3132
import javax.annotation.Nullable;
3233
import org.snakeyaml.engine.v2.api.Load;
3334
import org.snakeyaml.engine.v2.api.LoadSettings;
35+
import org.snakeyaml.engine.v2.api.YamlUnicodeReader;
3436
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;
37+
import org.snakeyaml.engine.v2.composer.Composer;
3838
import org.snakeyaml.engine.v2.nodes.MappingNode;
3939
import org.snakeyaml.engine.v2.nodes.Node;
4040
import org.snakeyaml.engine.v2.nodes.NodeTuple;
4141
import org.snakeyaml.engine.v2.nodes.ScalarNode;
42+
import org.snakeyaml.engine.v2.nodes.Tag;
43+
import org.snakeyaml.engine.v2.parser.ParserImpl;
44+
import org.snakeyaml.engine.v2.resolver.ScalarResolver;
45+
import org.snakeyaml.engine.v2.scanner.StreamReader;
4246
import org.snakeyaml.engine.v2.schema.CoreSchema;
4347

4448
/**
@@ -126,8 +130,9 @@ public static OpenTelemetrySdk create(
126130
/**
127131
* Parse the {@code configuration} YAML and return the {@link OpenTelemetryConfigurationModel}.
128132
*
129-
* <p>Before parsing, environment variable substitution is performed as described in {@link
130-
* EnvSubstitutionConstructor}.
133+
* <p>During parsing, environment variable substitution is performed as defined in the
134+
* <a href="https://opentelemetry.io/docs/specs/otel/configuration/data-model/#environment-variable-substitution">
135+
* OpenTelemetry Configuration Data Model specification</a>.
131136
*
132137
* @throws DeclarativeConfigException if unable to parse
133138
*/
@@ -149,7 +154,7 @@ static OpenTelemetryConfigurationModel parse(
149154
// Visible for testing
150155
static Object loadYaml(InputStream inputStream, Map<String, String> environmentVariables) {
151156
LoadSettings settings = LoadSettings.builder().setSchema(new CoreSchema()).build();
152-
Load yaml = new Load(settings, new EnvSubstitutionConstructor(settings, environmentVariables));
157+
Load yaml = new EnvLoad(settings, environmentVariables);
153158
return yaml.loadFromInputStream(inputStream);
154159
}
155160

@@ -241,89 +246,102 @@ static <M, R> R createAndMaybeCleanup(Factory<M, R> factory, SpiHelper spiHelper
241246
}
242247
}
243248

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

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

261-
private EnvSubstitutionConstructor(
262-
LoadSettings loadSettings, Map<String, String> environmentVariables) {
263-
super(loadSettings);
264-
load = new Load(loadSettings);
299+
public EnvComposer(LoadSettings settings, ParserImpl parser,
300+
Map<String, String> environmentVariables) {
301+
super(settings, parser);
302+
this.load = new Load(settings);
265303
this.environmentVariables = environmentVariables;
304+
this.scalarResolver = settings.getSchema().getScalarResolver();
266305
}
267306

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-
*/
274307
@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-
}
308+
protected void composeMappingChildren(List<NodeTuple> children, MappingNode node) {
309+
Node itemKey = composeKeyNode(node);
310+
Node itemValue = composeValueNode(node);
311+
// Only apply environment variable substitution to ScalarNodes
312+
if (!(itemValue instanceof ScalarNode)) {
313+
children.add(new NodeTuple(itemKey, itemValue));
314+
return;
306315
}
307316

308-
return mapping;
309-
}
317+
String envSubstitution = envSubstitution(((ScalarNode) itemValue).getValue());
310318

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 = '$';
314-
315-
private Object constructValueObject(Node node) {
316-
Object value = constructObject(node);
317-
if (!(node instanceof ScalarNode)) {
318-
return value;
319+
// If the environment variable substitution does not change the value, do not modify the node
320+
if (envSubstitution.equals(((ScalarNode) itemValue).getValue())) {
321+
children.add(new NodeTuple(itemKey, itemValue));
322+
return;
319323
}
320-
if (!(value instanceof String)) {
321-
return value;
324+
325+
Object envSubstitutionObj = load.loadFromString(envSubstitution);
326+
Tag tag = itemValue.getTag();
327+
ScalarStyle scalarStyle = ((ScalarNode) itemValue).getScalarStyle();
328+
329+
Tag resolvedTag = envSubstitutionObj == null ? Tag.NULL
330+
: scalarResolver.resolve(envSubstitutionObj.toString(), true);
331+
332+
if ((!scalarStyle.equals(ScalarStyle.SINGLE_QUOTED) && !scalarStyle.equals(
333+
ScalarStyle.DOUBLE_QUOTED)) || itemValue.getTag().equals(resolvedTag)) {
334+
tag = resolvedTag;
322335
}
323336

324-
String val = (String) value;
325-
ScalarStyle scalarStyle = ((ScalarNode) node).getScalarStyle();
337+
boolean resolved = true;
338+
itemValue = new ScalarNode(tag, resolved, envSubstitution,
339+
scalarStyle, itemValue.getStartMark(),
340+
itemValue.getEndMark());
341+
children.add(new NodeTuple(itemKey, itemValue));
342+
}
326343

344+
private String envSubstitution(String val) {
327345
// Iterate through val left to right, search for escape sequence "$$"
328346
// For the substring of val between the last escape sequence and the next found, perform
329347
// environment variable substitution
@@ -346,13 +364,7 @@ private Object constructValueObject(Node node) {
346364
}
347365
}
348366

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());
367+
return newVal.toString();
356368
}
357369

358370
private StringBuilder envVarSubstitution(

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

Lines changed: 26 additions & 4 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;
@@ -102,9 +103,9 @@ class DeclarativeConfigurationParseTest {
102103
@Test
103104
void parse_BadInputStream() {
104105
assertThatThrownBy(
105-
() ->
106-
DeclarativeConfiguration.parseAndCreate(
107-
new ByteArrayInputStream("foo".getBytes(StandardCharsets.UTF_8))))
106+
() ->
107+
DeclarativeConfiguration.parseAndCreate(
108+
new ByteArrayInputStream("foo".getBytes(StandardCharsets.UTF_8))))
108109
.isInstanceOf(DeclarativeConfigException.class)
109110
.hasMessage("Unable to parse configuration input stream");
110111
}
@@ -855,6 +856,26 @@ void parse_nullBoxedPrimitivesParsedToNull() {
855856
.withTraceIdRatioBased(new TraceIdRatioBasedSamplerModel()))));
856857
}
857858

859+
@Test
860+
void parse_quotedInput() {
861+
String yaml = "resource:\n"
862+
+ " attributes:\n"
863+
+ " - name: single_quote\n"
864+
+ " value: '\"single\"'\n"
865+
+ " - name: double_quote\n"
866+
+ " value: \"\\\"double\\\"\"";
867+
868+
OpenTelemetryConfigurationModel model =
869+
DeclarativeConfiguration.parse(
870+
new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)));
871+
872+
Assertions.assertNotNull(model.getResource());
873+
assertThat(model.getResource().getAttributes())
874+
.containsExactly(
875+
new AttributeNameValueModel().withName("single_quote").withValue("\"single\""),
876+
new AttributeNameValueModel().withName("double_quote").withValue("\"double\""));
877+
}
878+
858879
@ParameterizedTest
859880
@MethodSource("coreSchemaValuesArgs")
860881
void coreSchemaValues(String rawYaml, Object expectedYamlResult) {
@@ -928,7 +949,7 @@ private static java.util.stream.Stream<Arguments> envVarSubstitutionArgs() {
928949
// Undefined / empty environment variable
929950
Arguments.of("key1: ${EMPTY_STR}\n", mapOf(entry("key1", null))),
930951
Arguments.of("key1: ${STR_3}\n", mapOf(entry("key1", null))),
931-
Arguments.of("key1: ${STR_1} ${STR_3}\n", mapOf(entry("key1", "value1"))),
952+
Arguments.of("key1: ${STR_1} ${STR_3}\n", mapOf(entry("key1", "value1 "))),
932953
// Environment variable keys must match pattern: [a-zA-Z_]+[a-zA-Z0-9_]*
933954
Arguments.of("key1: ${VAR&}\n", mapOf(entry("key1", "${VAR&}"))),
934955
// Environment variable substitution only takes place in scalar values of maps
@@ -940,6 +961,7 @@ private static java.util.stream.Stream<Arguments> envVarSubstitutionArgs() {
940961
"key1:\n - ${STR_1}\n", mapOf(entry("key1", Collections.singletonList("${STR_1}")))),
941962
// Quoted environment variables
942963
Arguments.of("key1: \"${HEX}\"\n", mapOf(entry("key1", "0xdeadbeef"))),
964+
Arguments.of("key1: \"${HEX} ${HEX}\"\n", mapOf(entry("key1", "0xdeadbeef 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"))),

0 commit comments

Comments
 (0)