Skip to content

Commit 833bacb

Browse files
authored
fix: Don't evaluate substitutions hidden by values from resolved objects (#839)
1 parent dd3a0df commit 833bacb

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

config/src/main/java/com/typesafe/config/impl/ConfigDelayedMerge.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import java.util.ArrayList;
77
import java.util.Collection;
88
import java.util.Collections;
9+
import java.util.LinkedHashMap;
910
import java.util.List;
11+
import java.util.Map;
1012

1113
import com.typesafe.config.ConfigException;
1214
import com.typesafe.config.ConfigOrigin;
@@ -82,6 +84,16 @@ static ResolveResult<? extends AbstractConfigValue> resolveSubstitutions(Replace
8284
int count = 0;
8385
AbstractConfigValue merged = null;
8486
for (AbstractConfigValue end : stack) {
87+
// Per the HOCON spec, a substitution hidden by a value that
88+
// cannot be merged with it is never evaluated. If merged already
89+
// ignores fallbacks, nothing below can contribute, so stop.
90+
if (merged != null && merged.ignoresFallbacks()) {
91+
if (ConfigImpl.traceSubstitutionsEnabled())
92+
ConfigImpl.trace(newContext.depth(),
93+
"merged ignores fallbacks, skipping remaining stack");
94+
break;
95+
}
96+
8597
// the end value may or may not be resolved already
8698

8799
ResolveSource sourceForEnd;
@@ -120,6 +132,23 @@ else if (end instanceof Unmergeable) {
120132
"will resolve end against the original source with parent pushed");
121133

122134
sourceForEnd = source.pushParent(replaceable);
135+
136+
// Same spec rule as the short-circuit above, applied per-key:
137+
// keys in the lower-priority 'end' object that are already
138+
// shadowed in 'merged' by a value ignoring fallbacks would be
139+
// discarded by the subsequent merge, so don't resolve them.
140+
if (merged instanceof AbstractConfigObject && end instanceof AbstractConfigObject) {
141+
AbstractConfigObject prunedEnd = pruneShadowedKeys(
142+
(AbstractConfigObject) end, (AbstractConfigObject) merged);
143+
if (prunedEnd == null) {
144+
if (ConfigImpl.traceSubstitutionsEnabled())
145+
ConfigImpl.trace(newContext.depth(),
146+
"all keys in end are shadowed by merged, skipping");
147+
count += 1;
148+
continue;
149+
}
150+
end = prunedEnd;
151+
}
123152
}
124153

125154
if (ConfigImpl.traceSubstitutionsEnabled()) {
@@ -152,6 +181,38 @@ else if (end instanceof Unmergeable) {
152181
return ResolveResult.make(newContext, merged);
153182
}
154183

184+
// Returns a copy of 'end' with keys removed when 'merged' already has a
185+
// value at that key which ignores fallbacks. Returns null if every key in
186+
// 'end' is shadowed. Returns 'end' unchanged if no pruning applies or if
187+
// we cannot safely inspect merged (e.g. unresolved CDMO).
188+
private static AbstractConfigObject pruneShadowedKeys(AbstractConfigObject end,
189+
AbstractConfigObject merged) {
190+
if (!(end instanceof SimpleConfigObject))
191+
return end;
192+
SimpleConfigObject simple = (SimpleConfigObject) end;
193+
Map<String, AbstractConfigValue> kept = new LinkedHashMap<String, AbstractConfigValue>();
194+
boolean pruned = false;
195+
for (String key : simple.keySet()) {
196+
AbstractConfigValue mergedValue;
197+
try {
198+
mergedValue = merged.attemptPeekWithPartialResolve(key);
199+
} catch (ConfigException.NotResolved e) {
200+
return end;
201+
}
202+
if (mergedValue != null && mergedValue.ignoresFallbacks()) {
203+
pruned = true;
204+
} else {
205+
kept.put(key, simple.attemptPeekWithPartialResolve(key));
206+
}
207+
}
208+
if (!pruned)
209+
return end;
210+
if (kept.isEmpty())
211+
return null;
212+
return new SimpleConfigObject(end.origin(), kept,
213+
ResolveStatus.fromValues(kept.values()), false);
214+
}
215+
155216
@Override
156217
public AbstractConfigValue makeReplacement(ResolveContext context, int skipping) {
157218
return ConfigDelayedMerge.makeReplacement(context, stack, skipping);

config/src/test/scala/com/typesafe/config/impl/ConfigSubstitutionTest.scala

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,4 +1277,70 @@ class ConfigSubstitutionTest extends TestUtils {
12771277
val resolved2 = resolve(obj2)
12781278
assertEquals(parseObject("{ x : 42, y : 42 }"), resolved2.getConfig("a").root)
12791279
}
1280+
1281+
// Reproduces https://github.com/lightbend/config/issues/838
1282+
// A substitution hidden by a value that could not be merged with it should
1283+
// never be evaluated. This works when the hiding value is written directly,
1284+
// but not when it arrives via another substitution that resolves to an object.
1285+
@Test
1286+
def substitutionHiddenByObjectFromSubstitutionIsNotEvaluated() {
1287+
// Sanity: the direct-literal case works as the spec describes.
1288+
val direct = parseObject("""
1289+
p: { a : ${y} }
1290+
p: { a : 42 }
1291+
p: { a : { x : 1 } }
1292+
""")
1293+
val resolvedDirect = resolve(direct)
1294+
assertEquals(parseObject("""{ a : { x : 1 } }"""), resolvedDirect.getConfig("p").root)
1295+
1296+
// The middle hiding value is supplied via a substitution to an object.
1297+
// Per spec intent, ${y} should still never be evaluated.
1298+
val viaSub = parseObject("""
1299+
p: { a : ${y} }
1300+
p: ${x}
1301+
p: { a : { x : 1 } }
1302+
x: { a : 42 }
1303+
""")
1304+
val resolvedViaSub = resolve(viaSub)
1305+
assertEquals(parseObject("""{ a : { x : 1 } }"""), resolvedViaSub.getConfig("p").root)
1306+
}
1307+
1308+
// Spec (HOCON.md): "If a substitution is hidden by a value that could not
1309+
// be merged with it (by a non-object value) then it is never evaluated and
1310+
// no error will be reported." The direct top-level case from the spec.
1311+
@Test
1312+
def substitutionHiddenByLiteralNonObjectIsNotEvaluated() {
1313+
val obj = parseObject("""
1314+
foo: ${does-not-exist}
1315+
foo: 42
1316+
""")
1317+
val resolved = resolve(obj)
1318+
assertEquals(42, resolved.getInt("foo"))
1319+
}
1320+
1321+
// Variant of #838: an object containing a substitution is hidden by a
1322+
// literal non-object. The object cannot merge with 42, so the enclosed
1323+
// substitution must not be evaluated.
1324+
@Test
1325+
def substitutionInsideObjectHiddenByLiteralNonObjectIsNotEvaluated() {
1326+
val obj = parseObject("""
1327+
p: { a : ${does-not-exist} }
1328+
p: 42
1329+
""")
1330+
val resolved = resolve(obj)
1331+
assertEquals(42, resolved.getInt("p"))
1332+
}
1333+
1334+
// Variant of #838: an object containing a substitution is hidden by a
1335+
// substitution that resolves to a non-object. Same spec intent applies.
1336+
@Test
1337+
def substitutionInsideObjectHiddenByNonObjectFromSubstitutionIsNotEvaluated() {
1338+
val obj = parseObject("""
1339+
p: { a : ${does-not-exist} }
1340+
p: ${z}
1341+
z: 42
1342+
""")
1343+
val resolved = resolve(obj)
1344+
assertEquals(42, resolved.getInt("p"))
1345+
}
12801346
}

0 commit comments

Comments
 (0)