Skip to content

Commit e7d669a

Browse files
committed
fix: Don't evaluate substitutions hidden by values from resolved objects
1 parent edccc19 commit e7d669a

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

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

Lines changed: 57 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.HashMap;
910
import java.util.List;
11+
import java.util.Map;
1012

1113
import com.typesafe.config.ConfigException;
1214
import com.typesafe.config.ConfigOrigin;
@@ -120,6 +122,29 @@ else if (end instanceof Unmergeable) {
120122
"will resolve end against the original source with parent pushed");
121123

122124
sourceForEnd = source.pushParent(replaceable);
125+
126+
// Per the HOCON spec, a substitution hidden by a value that
127+
// cannot be merged with it is never evaluated. When merging
128+
// objects field-by-field the merged object may already contain,
129+
// at some keys, values that ignore fallbacks (e.g. a resolved
130+
// non-object shadowing a substitution below). Any substitution
131+
// at those same keys in a lower-priority object on the stack
132+
// would be discarded by the subsequent merge anyway, so we
133+
// prune those keys before resolving to avoid evaluating
134+
// substitutions that cannot contribute to the result.
135+
if (merged instanceof AbstractConfigObject && end instanceof AbstractConfigObject
136+
&& !(end instanceof Unmergeable)) {
137+
AbstractConfigObject prunedEnd = pruneShadowedKeys(
138+
(AbstractConfigObject) end, (AbstractConfigObject) merged);
139+
if (prunedEnd == null) {
140+
if (ConfigImpl.traceSubstitutionsEnabled())
141+
ConfigImpl.trace(newContext.depth(),
142+
"all keys in end are shadowed by merged, skipping");
143+
count += 1;
144+
continue;
145+
}
146+
end = prunedEnd;
147+
}
123148
}
124149

125150
if (ConfigImpl.traceSubstitutionsEnabled()) {
@@ -152,6 +177,38 @@ else if (end instanceof Unmergeable) {
152177
return ResolveResult.make(newContext, merged);
153178
}
154179

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

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,4 +1277,31 @@ 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+
}
12801307
}

0 commit comments

Comments
 (0)