diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java b/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java index cd64c5dada..14c903debe 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java @@ -12,15 +12,26 @@ import java.io.InputStreamReader; import java.io.Reader; import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.logging.Logger; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.composer.Composer; import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.nodes.MappingNode; import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.SequenceNode; import org.yaml.snakeyaml.parser.ParserImpl; import org.yaml.snakeyaml.reader.StreamReader; import org.yaml.snakeyaml.resolver.Resolver; @@ -35,9 +46,16 @@ public final class YamlUtils { public static Node merge(List sources, ConfigurationContext context) throws ConfiguratorException { Node root = null; MergeStrategy mergeStrategy = MergeStrategyFactory.getMergeStrategyOrDefault(context.getMergeStrategy()); + Map parsedCache = new HashMap<>(); + for (YamlSource source : sources) { try (Reader reader = reader(source)) { - final Node node = read(source, reader, context); + Node node = read(source, reader, context); + if (node != null) { + Set visited = new HashSet<>(); + visited.add(getCanonicalId(source)); + node = resolveExtends(node, source, context, visited, parsedCache); + } if (root == null) { root = node; @@ -128,4 +146,308 @@ public Node getSingleNode() { }); return (Mapping) constructor.getSingleData(Mapping.class); } + + private static Node resolveExtends( + Node node, + YamlSource currentSource, + ConfigurationContext context, + Set visited, + Map parsedCache) + throws ConfiguratorException { + + if (node instanceof MappingNode mapNode) { + List originalTuples = mapNode.getValue(); + List resolvedTuples = new ArrayList<>(); + + List extendsPaths = new ArrayList<>(); + boolean hasChanges = false; + + for (NodeTuple tuple : originalTuples) { + Node keyNode = tuple.getKeyNode(); + Node valueNode = tuple.getValueNode(); + + if (keyNode instanceof ScalarNode key && "_extends".equals(key.getValue())) { + if (valueNode instanceof ScalarNode scalar) { + if (scalar.getValue() == null + || scalar.getValue().trim().isEmpty()) { + throw new ConfiguratorException("The '_extends' property cannot be empty."); + } + extendsPaths.add(scalar.getValue()); + } else if (valueNode instanceof SequenceNode seq) { + if (seq.getValue().isEmpty()) { + throw new ConfiguratorException("The '_extends' list cannot be empty."); + } + for (Node item : seq.getValue()) { + if (item instanceof ScalarNode scalarItem) { + String path = scalarItem.getValue(); + if (path == null || path.trim().isEmpty()) { + throw new ConfiguratorException( + "Items in the '_extends' list cannot be null or empty strings."); + } + extendsPaths.add(path); + } else { + throw new ConfiguratorException(String.format( + "Invalid item in '_extends': expected string but got %s in %s", + item.getNodeId(), currentSource)); + } + } + } else { + throw new ConfiguratorException(String.format( + "Invalid value for '_extends' key. Expected string or list of strings, but found: %s", + valueNode.getNodeId())); + } + hasChanges = true; + continue; + } + + Node resolvedValue = + resolveExtends(valueNode, currentSource, context, Set.copyOf(visited), parsedCache); + + if (resolvedValue != valueNode) { + resolvedTuples.add(new NodeTuple(keyNode, resolvedValue)); + hasChanges = true; + } else { + resolvedTuples.add(tuple); + } + } + + if (!hasChanges) { + return node; + } + + MappingNode newMapNode = new MappingNode( + mapNode.getTag(), + true, + resolvedTuples, + mapNode.getStartMark(), + mapNode.getEndMark(), + mapNode.getFlowStyle()); + + if (!extendsPaths.isEmpty()) { + Node baseNode = null; + + for (String path : extendsPaths) { + YamlSource parentSource = resolveRelativeSource(currentSource, path); + String parentId = getCanonicalId(parentSource); + + if (visited.contains(parentId)) { + throw new ConfiguratorException("Circular _extends dependency detected: " + parentId); + } + + Node parentNode; + if (parsedCache.containsKey(parentId)) { + parentNode = cloneNode(parsedCache.get(parentId)); + } else { + Set newVisited = new HashSet<>(visited); + newVisited.add(parentId); + + try (Reader parentReader = reader(parentSource)) { + parentNode = read(parentSource, parentReader, context); + } catch (IOException | YAMLException e) { + throw new ConfiguratorException("Failed to read extended config: " + path, e); + } + + parentNode = + resolveExtends(parentNode, parentSource, context, Set.copyOf(newVisited), parsedCache); + + parentNode = cloneNode(parentNode); + parsedCache.put(parentId, parentNode); + } + + if (baseNode == null) { + baseNode = parentNode; + } else { + baseNode = deepMergeNodes(baseNode, parentNode); + } + } + + return deepMergeNodes(baseNode, newMapNode); + } + + return newMapNode; + + } else if (node instanceof SequenceNode seqNode) { + List originalChildren = seqNode.getValue(); + List resolvedChildren = new ArrayList<>(); + boolean hasChanges = false; + + for (Node child : originalChildren) { + Node resolvedChild = resolveExtends(child, currentSource, context, Set.copyOf(visited), parsedCache); + resolvedChildren.add(resolvedChild); + if (resolvedChild != child) { + hasChanges = true; + } + } + + if (hasChanges) { + return new SequenceNode( + seqNode.getTag(), + true, + resolvedChildren, + seqNode.getStartMark(), + seqNode.getEndMark(), + seqNode.getFlowStyle()); + } + } + + return node; + } + + private static YamlSource resolveRelativeSource(YamlSource currentSource, String extendsPath) + throws ConfiguratorException { + if (ConfigurationAsCode.isSupportedURI(extendsPath)) { + return YamlSource.of(extendsPath); + } + + Object src = currentSource.source; + + if (src instanceof Path currentPath) { + Path resolvedPath = currentPath.resolveSibling(extendsPath).normalize(); + + if (!Files.exists(resolvedPath)) { + throw new ConfiguratorException("Extended configuration file does not exist: " + resolvedPath); + } + return YamlSource.of(resolvedPath); + + } else if (src instanceof String) { + try { + URI currentUri = new URI((String) src); + URI resolvedUri = currentUri.resolve(extendsPath).normalize(); + return YamlSource.of(resolvedUri.toString()); + } catch (URISyntaxException e) { + throw new ConfiguratorException("Invalid base URI to resolve against: " + src, e); + } + + } else if (src instanceof HttpServletRequest || src instanceof InputStream) { + throw new ConfiguratorException( + "Relative `_extends` paths ('" + extendsPath + "') are not supported for inline configurations. " + + "Use an absolute file: or http(s): URL instead."); + } + + throw new ConfiguratorException("Cannot resolve relative path '" + extendsPath + "' for source type: " + + src.getClass().getSimpleName()); + } + + private static String extractKey(Node keyNode) throws ConfiguratorException { + if (keyNode instanceof ScalarNode scalarKey) { + return scalarKey.getValue(); + } + throw new ConfiguratorException(String.format( + "Invalid YAML key type: %s. JCasC only supports scalar (string) keys.", keyNode.getNodeId())); + } + + private static Node deepMergeNodes(Node base, Node override) throws ConfiguratorException { + if (base != null && override != null) { + boolean isBaseMap = base instanceof MappingNode; + boolean isBaseSeq = base instanceof SequenceNode; + boolean isOverrideMap = override instanceof MappingNode; + boolean isOverrideSeq = override instanceof SequenceNode; + + if ((isBaseMap && isOverrideSeq) || (isBaseSeq && isOverrideMap)) { + throw new ConfiguratorException(String.format( + "Type mismatch during merge: Cannot merge a %s and a %s. " + + "Check your '_extends' hierarchy for incompatible data structures.", + override.getNodeId(), base.getNodeId())); + } + } + if (base instanceof MappingNode baseMap && override instanceof MappingNode overrideMap) { + Map mergedTuples = new LinkedHashMap<>(); + + for (NodeTuple bTuple : baseMap.getValue()) { + String key = extractKey(bTuple.getKeyNode()); + mergedTuples.put(key, bTuple); + } + + for (NodeTuple oTuple : overrideMap.getValue()) { + String key = extractKey(oTuple.getKeyNode()); + + if (mergedTuples.containsKey(key)) { + Node bValue = mergedTuples.get(key).getValueNode(); + Node oValue = oTuple.getValueNode(); + + if (bValue instanceof MappingNode && oValue instanceof MappingNode) { + Node mergedValue = deepMergeNodes(bValue, oValue); + mergedTuples.put(key, new NodeTuple(oTuple.getKeyNode(), mergedValue)); + } else { + mergedTuples.put(key, new NodeTuple(oTuple.getKeyNode(), cloneNode(oValue))); + } + } else { + mergedTuples.put(key, new NodeTuple(oTuple.getKeyNode(), cloneNode(oTuple.getValueNode()))); + } + } + + return new MappingNode( + overrideMap.getTag(), + true, + new ArrayList<>(mergedTuples.values()), + baseMap.getStartMark(), + overrideMap.getEndMark(), + overrideMap.getFlowStyle()); + } + return cloneNode(override); + } + + private static Node cloneNode(Node node) { + if (node == null) { + return null; + } + + if (node instanceof MappingNode mapNode) { + List clonedTuples = new ArrayList<>(); + for (NodeTuple tuple : mapNode.getValue()) { + clonedTuples.add(new NodeTuple(cloneNode(tuple.getKeyNode()), cloneNode(tuple.getValueNode()))); + } + return new MappingNode( + mapNode.getTag(), + true, + clonedTuples, + mapNode.getStartMark(), + mapNode.getEndMark(), + mapNode.getFlowStyle()); + + } else if (node instanceof SequenceNode seqNode) { + List clonedChildren = new ArrayList<>(); + for (Node child : seqNode.getValue()) { + clonedChildren.add(cloneNode(child)); + } + return new SequenceNode( + seqNode.getTag(), + true, + clonedChildren, + seqNode.getStartMark(), + seqNode.getEndMark(), + seqNode.getFlowStyle()); + } + + if (node instanceof ScalarNode scalarNode) { + return new ScalarNode( + scalarNode.getTag(), + scalarNode.getValue(), + scalarNode.getStartMark(), + scalarNode.getEndMark(), + scalarNode.getScalarStyle()); + } + + return node; + } + + private static String getCanonicalId(YamlSource source) { + Object src = source.source; + + if (src instanceof Path path) { + try { + return path.toRealPath().toString(); + } catch (IOException e) { + return path.toAbsolutePath().normalize().toString(); + } + } else if (src instanceof String url) { + try { + return new URI(url).normalize().toString(); + } catch (URISyntaxException e) { + return url; + } + } + + return source.toString(); + } } diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlExtendsTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlExtendsTest.java new file mode 100644 index 0000000000..83da3297bf --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/yaml/YamlExtendsTest.java @@ -0,0 +1,611 @@ +package io.jenkins.plugins.casc.yaml; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.jenkins.plugins.casc.ConfigurationContext; +import io.jenkins.plugins.casc.ConfiguratorException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Objects; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.JenkinsRule; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeId; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.SequenceNode; +import org.yaml.snakeyaml.nodes.Tag; + +public class YamlExtendsTest { + + @Rule + public JenkinsRule jenkins = new JenkinsRule(); + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + private ConfigurationContext context; + + @Before + public void setUp() { + context = new ConfigurationContext(null) { + @Override + public String getMergeStrategy() { + return ""; + } + + @Override + public int getYamlCodePointLimit() { + return 3 * 1024 * 1024; + } + + @Override + public int getYamlMaxAliasesForCollections() { + return 50; + } + }; + } + + @Test + public void testSingleExtendsDeepMerge() throws Exception { + writeYaml("base.yaml", """ + jenkins: + systemMessage: 'Base Message' + numExecutors: 2 + """); + + Path child = writeYaml("child.yaml", """ + _extends: base.yaml + jenkins: + systemMessage: 'Override Message' + """); + + Node root = parse(child); + assertTrue("Root should be a MappingNode", root instanceof MappingNode); + + MappingNode map = (MappingNode) root; + MappingNode jenkinsMap = (MappingNode) getChildNode(map, "jenkins"); + + assertEquals("Override Message", getScalarValue(jenkinsMap, "systemMessage")); + assertEquals("2", getScalarValue(jenkinsMap, "numExecutors")); + } + + @Test + public void testMultipleExtendsSequentialOverride() throws Exception { + writeYaml("base1.yaml", "a: 1\nb: 1"); + writeYaml("base2.yaml", "b: 2\nc: 2"); + + Path child = writeYaml("child.yaml", """ + _extends: [base1.yaml, base2.yaml] + c: 3 + d: 4"""); + + Node root = parse(child); + assertTrue(root instanceof MappingNode); + MappingNode map = (MappingNode) root; + + assertEquals("1", getScalarValue(map, "a")); + assertEquals("2", getScalarValue(map, "b")); + assertEquals("3", getScalarValue(map, "c")); + assertEquals("4", getScalarValue(map, "d")); + } + + @Test + public void testCircularDependencyThrowsException() throws Exception { + writeYaml("a.yaml", "_extends: b.yaml\nfoo: bar"); + Path b = writeYaml("b.yaml", "_extends: a.yaml\nbar: baz"); + + ConfiguratorException thrown = expectConfiguratorException(() -> parse(b)); + + assertThat( + "Exception should mention circular dependencies", + thrown.getMessage(), + containsString("Circular _extends dependency detected")); + } + + @Test + public void testTypeMismatchMapVsSequenceThrowsException() throws Exception { + writeYaml("list.yaml", """ + - item1 + - item2 + """); + + Path child = writeYaml("child.yaml", """ + _extends: list.yaml + key: value + """); + + ConfiguratorException thrown = expectConfiguratorException(() -> parse(child)); + + assertThat( + "Exception should catch the type mismatch", + thrown.getMessage(), + containsString("Type mismatch during merge")); + } + + @Test + public void testRelativePathResolutionSibling() throws Exception { + Path subDir = Files.createDirectory(tempDir.getRoot().toPath().resolve("subdir")); + writeYaml(subDir.resolve("base.yaml"), "val: success"); + + Path child = writeYaml(subDir.resolve("child.yaml"), "_extends: base.yaml"); + + Node root = parse(child); + assertEquals("success", getScalarValue((MappingNode) root, "val")); + } + + @Test + public void testEmptyExtendsThrowsException() throws Exception { + Path emptyStringExtends = writeYaml("empty_str.yaml", "_extends: ''"); + ConfiguratorException ex1 = assertThrows(ConfiguratorException.class, () -> parse(emptyStringExtends)); + assertThat(ex1.getMessage(), containsString("The '_extends' property cannot be empty.")); + + Path emptyListExtends = writeYaml("empty_list.yaml", "_extends: []"); + ConfiguratorException ex2 = assertThrows(ConfiguratorException.class, () -> parse(emptyListExtends)); + assertThat(ex2.getMessage(), containsString("The '_extends' list cannot be empty.")); + + Path nullExtends = writeYaml("null_item.yaml", "_extends: [ base.yaml, '' ]"); + ConfiguratorException ex3 = assertThrows(ConfiguratorException.class, () -> parse(nullExtends)); + assertThat(ex3.getMessage(), containsString("Items in the '_extends' list cannot be null")); + } + + private Path writeYaml(String filename, String content) throws IOException { + return writeYaml(tempDir.getRoot().toPath().resolve(filename), content); + } + + private Path writeYaml(Path path, String content) throws IOException { + Files.writeString(path, content); + return path; + } + + private Node parse(Path file) throws ConfiguratorException { + YamlSource source = YamlSource.of(file); + Node merged = YamlUtils.merge(Collections.singletonList(source), context); + + return Objects.requireNonNull(merged, "Parsed YAML returned null (is the file empty?)"); + } + + private Node getChildNode(MappingNode parent, String key) { + for (NodeTuple tuple : parent.getValue()) { + ScalarNode keyNode = (ScalarNode) tuple.getKeyNode(); + if (key.equals(keyNode.getValue())) { + return tuple.getValueNode(); + } + } + throw new AssertionError("Expected key '" + key + "' was not found in the YAML mapping."); + } + + private String getScalarValue(MappingNode parent, String key) { + Node child = getChildNode(parent, key); + + if (child instanceof ScalarNode) { + return ((ScalarNode) child).getValue(); + } + + throw new AssertionError("Expected key '" + key + "' to be a scalar string, but found a " + child.getNodeId()); + } + + @Test + public void testExtendsUsesSameFileMultipleTimesWithoutCircularError() throws Exception { + writeYaml("base.yaml", "a: 1"); + + Path child = writeYaml("child.yaml", """ + _extends: base.yaml + b: 2 + c: + _extends: base.yaml + """); + + Node root = parse(child); + MappingNode map = (MappingNode) root; + + assertEquals("1", getScalarValue(map, "a")); + assertEquals("2", getScalarValue(map, "b")); + + MappingNode cNode = (MappingNode) getChildNode(map, "c"); + assertEquals("1", getScalarValue(cNode, "a")); + } + + @Test + public void testRelativeExtendsWithoutBaseContextThrowsException() { + String uiYamlContent = """ + _extends: base.yaml + jenkins: + systemMessage: 'Hello' + """; + + InputStream inputStream = new ByteArrayInputStream(uiYamlContent.getBytes(StandardCharsets.UTF_8)); + + YamlSource source = YamlSource.of(inputStream); + + ConfiguratorException thrown = assertThrows( + ConfiguratorException.class, () -> YamlUtils.merge(Collections.singletonList(source), context)); + + assertThat( + "Exception should catch relative paths being used without a base context", + thrown.getMessage(), + containsString("Relative `_extends` paths")); + } + + @Test + public void testDeepNestedMultiLevelExtends() throws Exception { + writeYaml("grandparent.yaml", "grandparentKey: 'grandparentValue'"); + + writeYaml("parent.yaml", """ + _extends: grandparent.yaml + parentKey: 'parentValue' + """); + + Path childFile = writeYaml("child.yaml", """ + _extends: parent.yaml + childKey: 'childValue' + """); + + Node root = parse(childFile); + assertTrue(root instanceof MappingNode); + MappingNode map = (MappingNode) root; + + assertEquals("grandparentValue", getScalarValue(map, "grandparentKey")); + assertEquals("parentValue", getScalarValue(map, "parentKey")); + assertEquals("childValue", getScalarValue(map, "childKey")); + } + + private ConfiguratorException expectConfiguratorException(Runnable r) { + Exception ex = assertThrows(Exception.class, r::run); + + if (ex instanceof ConfiguratorException) { + return (ConfiguratorException) ex; + } + + if (ex.getCause() instanceof ConfiguratorException) { + return (ConfiguratorException) ex.getCause(); + } + + throw new AssertionError("Expected ConfiguratorException but got: " + ex); + } + + @Test + public void testExtendsListWithNonScalarItemThrowsException() throws Exception { + writeYaml("base.yaml", "a: 1"); + + Path child = writeYaml("child.yaml", """ + _extends: + - base.yaml + - key: value + """); + + ConfiguratorException ex = expectConfiguratorException(() -> parse(child)); + + assertThat(ex.getMessage(), containsString("Invalid item in '_extends'")); + } + + @Test + public void testExtendsWithInvalidTypeThrowsException() throws Exception { + Path child = writeYaml("child.yaml", """ + _extends: + key: value + """); + + ConfiguratorException ex = expectConfiguratorException(() -> parse(child)); + + assertThat(ex.getMessage(), containsString("Invalid value for '_extends' key")); + } + + @Test + public void testExtendsWithUnreadableFileThrowsException() throws Exception { + Path dir = tempDir.newFolder("not_a_file").toPath(); + + Path child = writeYaml("child.yaml", "_extends: " + dir.getFileName().toString()); + + ConfiguratorException ex = expectConfiguratorException(() -> parse(child)); + + assertThat(ex.getMessage(), containsString("Failed to read extended config")); + } + + @Test + public void testMappingWithoutExtendsButWithNestedChangesReturnsNewMapNode() throws Exception { + Path yaml = writeYaml("simple.yaml", """ + jenkins: + systemMessage: "hello" + """); + + Node root = parse(yaml); + + assertTrue(root instanceof MappingNode); + MappingNode map = (MappingNode) root; + + MappingNode jenkins = (MappingNode) getChildNode(map, "jenkins"); + assertEquals("hello", getScalarValue(jenkins, "systemMessage")); + } + + @Test + public void testSequenceNodeWithExtendsTriggersHasChanges() throws Exception { + writeYaml("base.yaml", """ + key: baseValue + """); + + Path child = writeYaml("child.yaml", """ + list: + - _extends: base.yaml + """); + + Node root = parse(child); + assertTrue(root instanceof MappingNode); + + SequenceNode list = (SequenceNode) getChildNode((MappingNode) root, "list"); + + MappingNode item = (MappingNode) list.getValue().get(0); + assertEquals("baseValue", getScalarValue(item, "key")); + } + + @Test + public void testExtendsWithFileUri() throws Exception { + Path base = writeYaml("base.yaml", """ + key: uriValue + """); + + String uriPath = base.toUri().toString(); + + Path child = writeYaml("child.yaml", "_extends: " + uriPath); + + Node root = parse(child); + + assertTrue(root instanceof MappingNode); + assertEquals("uriValue", getScalarValue((MappingNode) root, "key")); + } + + @Test + public void testExtendsWithHttpUriFormat() { + String fakeUri = "https://example.com/config.yaml"; + + ConfiguratorException ex = + assertThrows(ConfiguratorException.class, () -> parse(writeYaml("child.yaml", "_extends: " + fakeUri))); + + assertThat(ex.getMessage(), containsString("Failed to read extended config")); + } + + @Test + public void testExtendsWithNonExistentFileThrowsException() throws Exception { + Path child = writeYaml("child.yaml", """ + _extends: missing.yaml + key: value + """); + + ConfiguratorException ex = assertThrows(ConfiguratorException.class, () -> parse(child)); + + assertThat(ex.getMessage(), containsString("Extended configuration file does not exist")); + } + + @Test + public void testExtendsWithStringSourceUri() throws Exception { + writeYaml("base.yaml", "key: baseValue"); + + Path childFile = writeYaml("child.yaml", """ + _extends: base.yaml + """); + + String childUri = childFile.toUri().toString(); + + YamlSource source = YamlSource.of(childUri); + + Node root = YamlUtils.merge(Collections.singletonList(source), context); + + assertEquals("baseValue", getScalarValue((MappingNode) root, "key")); + } + + @Test + public void testUnsupportedSourceTypeThrowsException() throws Exception { + Object unsupported = new Object(); + YamlSource source = new YamlSource<>(unsupported); + + Method resolveMethod = + YamlUtils.class.getDeclaredMethod("resolveRelativeSource", YamlSource.class, String.class); + resolveMethod.setAccessible(true); + + InvocationTargetException ex = assertThrows( + InvocationTargetException.class, () -> resolveMethod.invoke(null, source, "dummy-path.yaml")); + + Throwable actualException = ex.getCause(); + assertTrue(actualException instanceof ConfiguratorException); + + assertThat( + actualException.getMessage(), + containsString("Cannot resolve relative path 'dummy-path.yaml' for source type: Object")); + } + + @Test + public void testInvalidYamlKeyTypeThrowsException() throws Exception { + writeYaml("base.yaml", """ + validKey: value + """); + + Path child = writeYaml("child.yaml", """ + _extends: base.yaml + ? [a, b] + : value + """); + + ConfiguratorException ex = assertThrows(ConfiguratorException.class, () -> parse(child)); + + assertThat(ex.getMessage(), containsString("Invalid YAML key type")); + } + + @Test + public void testSequenceOverrideUsesCloneNode() throws Exception { + writeYaml("base.yaml", """ + list: + - one + - two + """); + + Path child = writeYaml("child.yaml", """ + _extends: base.yaml + list: + - three + - four + """); + + Node root = parse(child); + + SequenceNode seq = (SequenceNode) getChildNode((MappingNode) root, "list"); + + assertEquals(2, seq.getValue().size()); + assertEquals("three", ((ScalarNode) seq.getValue().get(0)).getValue()); + } + + @Test + public void testCloneNodeWithNullValue() throws Exception { + writeYaml("base.yaml", """ + key: value + """); + + Path child = writeYaml("child.yaml", """ + _extends: base.yaml + key: + """); + + Node root = parse(child); + + MappingNode map = (MappingNode) root; + + Node valueNode = getChildNode(map, "key"); + + assertTrue(valueNode == null || valueNode instanceof ScalarNode); + } + + @Test + public void testCloneNodeWithAliasReturnsOriginalNode() throws Exception { + Path file = writeYaml("alias.yaml", """ + base: &anchor + key: value + copy: *anchor + """); + + Node root = parse(file); + + MappingNode map = (MappingNode) root; + + Node copyNode = getChildNode(map, "copy"); + + assertNotNull(copyNode); + } + + @Test + public void testCloneNodeWithUnknownNodeTypeTriggersFallback() throws Exception { + Node customNode = new Node(new Tag("!custom"), null, null) { + @Override + public NodeId getNodeId() { + return NodeId.anchor; + } + }; + + Method cloneNodeMethod = YamlUtils.class.getDeclaredMethod("cloneNode", Node.class); + cloneNodeMethod.setAccessible(true); + + Node result = (Node) cloneNodeMethod.invoke(null, customNode); + + assertSame("Fallback branch should return the original node instance", customNode, result); + } + + @Test + public void testInvalidUriFallbackBranch() { + String bad = "ht!tp://invalid uri"; + + assertThrows(Exception.class, () -> { + YamlSource source = YamlSource.of(bad); + YamlUtils.merge(Collections.singletonList(source), context); + }); + } + + @Test + public void testInvalidBaseUriThrowsException() throws Exception { + YamlSource source = YamlSource.of("ht!tp://invalid uri"); + + Method resolveMethod = + YamlUtils.class.getDeclaredMethod("resolveRelativeSource", YamlSource.class, String.class); + resolveMethod.setAccessible(true); + + InvocationTargetException ex = assertThrows( + InvocationTargetException.class, () -> resolveMethod.invoke(null, source, "dummy-path.yaml")); + + Throwable actualException = ex.getCause(); + assertTrue(actualException instanceof ConfiguratorException); + + assertThat(actualException.getMessage(), containsString("Invalid base URI to resolve against")); + } + + @Test + public void testScalarOverrideUsesCloneNode() throws Exception { + writeYaml("base.yaml", """ + key: baseValue + """); + + Path child = writeYaml("child.yaml", """ + _extends: base.yaml + key: overrideValue + """); + + Node root = parse(child); + + assertEquals("overrideValue", getScalarValue((MappingNode) root, "key")); + } + + @Test + public void testExtendsEmptyFileCallsCloneNodeWithNull() throws Exception { + writeYaml("empty.yaml", ""); + + Path child = writeYaml("child.yaml", """ + _extends: empty.yaml + key: value + """); + + Node root = parse(child); + + assertTrue(root instanceof MappingNode); + assertEquals("value", getScalarValue((MappingNode) root, "key")); + } + + @Test + public void testGetCanonicalIdWithNonExistentPathTriggersFallback() throws Exception { + Path missingPath = tempDir.getRoot().toPath().resolve("does_not_exist.yaml"); + YamlSource source = YamlSource.of(missingPath); + + Method getCanonicalIdMethod = YamlUtils.class.getDeclaredMethod("getCanonicalId", YamlSource.class); + getCanonicalIdMethod.setAccessible(true); + + String result = (String) getCanonicalIdMethod.invoke(null, source); + + String expectedFallback = missingPath.toAbsolutePath().normalize().toString(); + assertEquals(expectedFallback, result); + } + + @Test + public void testGetCanonicalIdWithInvalidUriTriggersFallback() throws Exception { + String invalidUri = "ht!tp://invalid uri with spaces^"; + YamlSource source = YamlSource.of(invalidUri); + + Method getCanonicalIdMethod = YamlUtils.class.getDeclaredMethod("getCanonicalId", YamlSource.class); + getCanonicalIdMethod.setAccessible(true); + + String result = (String) getCanonicalIdMethod.invoke(null, source); + + assertEquals(invalidUri, result); + } +}