Skip to content

Commit 7611299

Browse files
committed
fix: regression - partially-shadowed object has nested delayed merge
1 parent eb80714 commit 7611299

2 files changed

Lines changed: 61 additions & 40 deletions

File tree

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

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

1311
import com.typesafe.config.ConfigException;
1412
import com.typesafe.config.ConfigOrigin;
@@ -134,20 +132,19 @@ else if (end instanceof Unmergeable) {
134132
sourceForEnd = source.pushParent(replaceable);
135133

136134
// 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;
135+
// if every key in the lower-priority 'end' object is already
136+
// shadowed in 'merged' by a value ignoring fallbacks, the whole
137+
// 'end' would be discarded by the subsequent merge. Skip
138+
// resolving it. (We only skip the whole entry — substituting a
139+
// partial copy of 'end' would change identity and break parent
140+
// chain walks during inner substitution resolution.)
141+
if (merged instanceof AbstractConfigObject && end instanceof SimpleConfigObject
142+
&& allKeysShadowed((SimpleConfigObject) end, (AbstractConfigObject) merged)) {
143+
if (ConfigImpl.traceSubstitutionsEnabled())
144+
ConfigImpl.trace(newContext.depth(),
145+
"all keys in end are shadowed by merged, skipping");
146+
count += 1;
147+
continue;
151148
}
152149
}
153150

@@ -181,36 +178,22 @@ else if (end instanceof Unmergeable) {
181178
return ResolveResult.make(newContext, merged);
182179
}
183180

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()) {
181+
// True when every key in 'end' is shadowed by a value in 'merged' that
182+
// ignores fallbacks (so the subsequent merge would drop the whole 'end').
183+
private static boolean allKeysShadowed(SimpleConfigObject end, AbstractConfigObject merged) {
184+
if (end.isEmpty())
185+
return false;
186+
for (String key : end.keySet()) {
196187
AbstractConfigValue mergedValue;
197188
try {
198189
mergedValue = merged.attemptPeekWithPartialResolve(key);
199190
} 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));
191+
return false;
206192
}
193+
if (mergedValue == null || !mergedValue.ignoresFallbacks())
194+
return false;
207195
}
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);
196+
return true;
214197
}
215198

216199
@Override

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,4 +1343,42 @@ class ConfigSubstitutionTest extends TestUtils {
13431343
val resolved = resolve(obj)
13441344
assertEquals(42, resolved.getInt("p"))
13451345
}
1346+
1347+
// Regression: when a delayed-merge object's stack contains a SimpleConfigObject
1348+
// whose keys are partially shadowed by a higher-priority entry, pruning
1349+
// produces a SimpleConfigObject containing only the kept keys. If a kept key
1350+
// holds a nested ConfigDelayedMerge with an unmergeable element, resolving
1351+
// that inner CDM walks up the parent chain and reaches the outer CDMO, which
1352+
// still has the original (unpruned) entry in its stack — leading to
1353+
// ConfigException.BugOrBroken("tried to replace ... which is not in [...]").
1354+
@Test
1355+
def partiallyShadowedObjectWithInnerDelayedMergeResolves() {
1356+
// ${overrides} between the two `db:` definitions forces `db` into a
1357+
// ConfigDelayedMergeObject (the merge cannot happen at parse time).
1358+
// The lower-priority entry (defined first) has `database`, `user`, and
1359+
// `ssl`. The higher-priority entry (defined last) shadows `database`
1360+
// and `user` but does not define `ssl`, so pruning yields {ssl: …}.
1361+
// `ssl.cert` is a CDM stack of an optional substitution and "default",
1362+
// whose resolution walks back through the path to the outer CDMO.
1363+
val obj = parseObject("""
1364+
db: {
1365+
database: "fallback"
1366+
user: "fallback"
1367+
ssl: {
1368+
cert: "default"
1369+
cert: ${?SSL_CERT}
1370+
}
1371+
}
1372+
db: ${overrides}
1373+
db: {
1374+
database: "primary"
1375+
user: "admin"
1376+
}
1377+
overrides: {}
1378+
""")
1379+
val resolved = resolve(obj)
1380+
assertEquals("primary", resolved.getString("db.database"))
1381+
assertEquals("admin", resolved.getString("db.user"))
1382+
assertEquals("default", resolved.getString("db.ssl.cert"))
1383+
}
13461384
}

0 commit comments

Comments
 (0)