[CONFIGURATION-760] Properties file using cyclical includes cause a StackOverflowError instead of detecting the misconfiguration. (#35)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 149f142..5724ccc 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -72,6 +72,9 @@
       <action dev="ggregory" type="update" issue="CONFIGURATION-759" due-to="Gary Gregory">

         Update Spring from 4.3.24.RELEASE to 4.3.25.RELEASE.

       </action>

+      <action dev="ggregory" type="fix" issue="CONFIGURATION-760" due-to="Gary Gregory">

+        Properties file using cyclical includes cause a StackOverflowError instead of detecting the misconfiguration.

+      </action>

     </release>

     <release version="2.5" date="2019-05-23"

              description="Minor release with new features and updated dependencies.">

diff --git a/src/main/java/org/apache/commons/configuration2/PropertiesConfiguration.java b/src/main/java/org/apache/commons/configuration2/PropertiesConfiguration.java
index 438bba8..4df6827 100644
--- a/src/main/java/org/apache/commons/configuration2/PropertiesConfiguration.java
+++ b/src/main/java/org/apache/commons/configuration2/PropertiesConfiguration.java
@@ -27,6 +27,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Deque;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -643,11 +644,12 @@
      *
      * @param key the property key
      * @param value the property value
+     * @param seenStack the stack of seen include URLs
      * @return a flag whether this is a normal property
      * @throws ConfigurationException if an error occurs
      * @since 1.3
      */
-    boolean propertyLoaded(final String key, final String value)
+    boolean propertyLoaded(final String key, final String value, final Deque<URL> seenStack)
             throws ConfigurationException
     {
         boolean result;
@@ -661,7 +663,7 @@
                         getListDelimiterHandler().split(value, true);
                 for (final String f : files)
                 {
-                    loadIncludeFile(interpolate(f), false);
+                    loadIncludeFile(interpolate(f), false, seenStack);
                 }
             }
             result = false;
@@ -676,7 +678,7 @@
                         getListDelimiterHandler().split(value, true);
                 for (final String f : files)
                 {
-                    loadIncludeFile(interpolate(f), true);
+                    loadIncludeFile(interpolate(f), true, seenStack);
                 }
             }
             result = false;
@@ -1853,9 +1855,11 @@
      *
      * @param fileName the name of the file to load
      * @param optional whether or not the {@code fileName} is optional
+     * @param seenStack Stack of seen include URLs
      * @throws ConfigurationException if loading fails
      */
-    private void loadIncludeFile(final String fileName, final boolean optional) throws ConfigurationException
+    private void loadIncludeFile(final String fileName, final boolean optional, final Deque<URL> seenStack)
+            throws ConfigurationException
     {
         if (locator == null)
         {
@@ -1881,11 +1885,8 @@
 
         if (url == null)
         {
-            if (getIncludeListener() != null)
-            {
-                getIncludeListener().accept(new ConfigurationException(
-                        "Cannot resolve include file " + fileName, new FileNotFoundException(fileName)));
-            }
+            getIncludeListener().accept(new ConfigurationException("Cannot resolve include file " + fileName,
+                    new FileNotFoundException(fileName)));
         }
         else
         {
@@ -1896,14 +1897,25 @@
             {
                 try
                 {
-                    fh.load(url);
+                    // Check for cycles
+                    if (seenStack.contains(url))
+                    {
+                        throw new ConfigurationException(
+                                String.format("Cycle detected loading %s, seen stack: %s", url, seenStack));
+                    }
+                    seenStack.add(url);
+                    try
+                    {
+                        fh.load(url);
+                    }
+                    finally
+                    {
+                        seenStack.pop();
+                    }
                 }
                 catch (ConfigurationException e)
                 {
-                    if (getIncludeListener() != null)
-                    {
-                        getIncludeListener().accept(e);
-                    }
+                    getIncludeListener().accept(e);
                 }
             }
             finally
diff --git a/src/main/java/org/apache/commons/configuration2/PropertiesConfigurationLayout.java b/src/main/java/org/apache/commons/configuration2/PropertiesConfigurationLayout.java
index dffff50..4da001f 100644
--- a/src/main/java/org/apache/commons/configuration2/PropertiesConfigurationLayout.java
+++ b/src/main/java/org/apache/commons/configuration2/PropertiesConfigurationLayout.java
@@ -19,6 +19,8 @@
 import java.io.IOException;
 import java.io.Reader;
 import java.io.Writer;
+import java.net.URL;
+import java.util.ArrayDeque;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -138,6 +140,9 @@
     /** Stores the force single line flag. */
     private boolean forceSingleLine;
 
+    /** Seen includes. */
+    private final ArrayDeque<URL> seenStack = new ArrayDeque<>();
+
     /**
      * Creates a new, empty instance of {@code PropertiesConfigurationLayout}.
      */
@@ -486,7 +491,7 @@
             while (reader.nextProperty())
             {
                 if (config.propertyLoaded(reader.getPropertyName(),
-                        reader.getPropertyValue()))
+                        reader.getPropertyValue(), seenStack))
                 {
                     final boolean contained = layoutData.containsKey(reader
                             .getPropertyName());
@@ -653,6 +658,7 @@
      */
     private void clear()
     {
+        seenStack.clear();
         layoutData.clear();
         setHeaderComment(null);
         setFooterComment(null);
diff --git a/src/test/java/org/apache/commons/configuration2/TestPropertiesConfiguration.java b/src/test/java/org/apache/commons/configuration2/TestPropertiesConfiguration.java
index bd1e532..5b5e8f5 100644
--- a/src/test/java/org/apache/commons/configuration2/TestPropertiesConfiguration.java
+++ b/src/test/java/org/apache/commons/configuration2/TestPropertiesConfiguration.java
@@ -49,6 +49,7 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Paths;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
@@ -73,8 +74,8 @@
 import org.apache.commons.configuration2.io.FileHandler;
 import org.apache.commons.configuration2.io.FileSystem;
 import org.apache.commons.lang3.mutable.MutableObject;
+import org.junit.Assert;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
@@ -1147,8 +1148,82 @@
     }
 
     @Test
-    @Ignore("PropertiesConfiguration does NOT detect cyclical references.")
-    public void testIncludeLoadAllCycliclaReference() throws Exception
+    public void testIncludeLoadCyclicalReferenceFail() throws Exception
+    {
+        final PropertiesConfiguration pc = new PropertiesConfiguration();
+        final FileHandler handler = new FileHandler(pc);
+        handler.setBasePath(testBasePath);
+        handler.setFileName("include-cyclical-reference.properties");
+        try {
+            handler.load();
+            Assert.fail("Expected " + Configuration.class.getCanonicalName());
+        } catch (ConfigurationException e) {
+            // expected
+            // e.printStackTrace();
+        }
+        assertNull(pc.getString("keyA"));
+    }
+
+    @Test
+    public void testIncludeLoadCyclicalMultiStepReferenceFail() throws Exception
+    {
+        final PropertiesConfiguration pc = new PropertiesConfiguration();
+        final FileHandler handler = new FileHandler(pc);
+        handler.setBasePath(testBasePath);
+        handler.setFileName("include-cyclical-root.properties");
+        try {
+            handler.load();
+            Assert.fail("Expected " + Configuration.class.getCanonicalName());
+        } catch (ConfigurationException e) {
+            // expected
+            // e.printStackTrace();
+        }
+        assertNull(pc.getString("keyA"));
+    }
+
+    @Test
+    public void testIncludeLoadCyclicalMultiStepReferenceIgnore() throws Exception
+    {
+        final PropertiesConfiguration pc = new PropertiesConfiguration();
+        pc.setIncludeListener(PropertiesConfiguration.NOOP_INCLUDE_LISTENER);
+        final FileHandler handler = new FileHandler(pc);
+        handler.setBasePath(testBasePath);
+        handler.setFileName("include-cyclical-root.properties");
+        handler.load();
+        assertEquals("valueA", pc.getString("keyA"));
+    }
+
+    @Test
+    public void testIncludeIncludeLoadCyclicalReferenceFail() throws Exception
+    {
+        final PropertiesConfiguration pc = new PropertiesConfiguration();
+        final FileHandler handler = new FileHandler(pc);
+        handler.setBasePath(testBasePath);
+        handler.setFileName("include-include-cyclical-reference.properties");
+        try {
+            handler.load();
+            Assert.fail("Expected " + Configuration.class.getCanonicalName());
+        } catch (ConfigurationException e) {
+            // expected
+            // e.printStackTrace();
+        }
+        assertNull(pc.getString("keyA"));
+    }
+
+    @Test
+    public void testIncludeIncludeLoadCyclicalReferenceIgnore() throws Exception
+    {
+        final PropertiesConfiguration pc = new PropertiesConfiguration();
+        pc.setIncludeListener(PropertiesConfiguration.NOOP_INCLUDE_LISTENER);
+        final FileHandler handler = new FileHandler(pc);
+        handler.setBasePath(testBasePath);
+        handler.setFileName("include-include-cyclical-reference.properties");
+        handler.load();
+        assertEquals("valueA", pc.getString("keyA"));
+    }
+
+    @Test
+    public void testIncludeLoadCyclicalReferenceIgnore() throws Exception
     {
         final PropertiesConfiguration pc = new PropertiesConfiguration();
         pc.setIncludeListener(PropertiesConfiguration.NOOP_INCLUDE_LISTENER);
@@ -1231,7 +1306,7 @@
     {
         final DummyLayout layout = new DummyLayout();
         conf.setLayout(layout);
-        conf.propertyLoaded("layoutLoadedProperty", "yes");
+        conf.propertyLoaded("layoutLoadedProperty", "yes", null);
         assertEquals("Layout's load() was called", 0, layout.loadCalls);
         assertEquals("Property not added", "yes", conf.getString("layoutLoadedProperty"));
     }
@@ -1244,7 +1319,8 @@
     {
         final DummyLayout layout = new DummyLayout();
         conf.setLayout(layout);
-        conf.propertyLoaded(PropertiesConfiguration.getInclude(), "testClasspath.properties,testEqual.properties");
+        conf.propertyLoaded(PropertiesConfiguration.getInclude(), "testClasspath.properties,testEqual.properties",
+                new ArrayDeque<>());
         assertEquals("Layout's load() was not correctly called", 2, layout.loadCalls);
         assertFalse("Property was added", conf.containsKey(PropertiesConfiguration.getInclude()));
     }
@@ -1259,7 +1335,7 @@
         final DummyLayout layout = new DummyLayout();
         conf.setLayout(layout);
         conf.setIncludesAllowed(false);
-        conf.propertyLoaded(PropertiesConfiguration.getInclude(), "testClassPath.properties,testEqual.properties");
+        conf.propertyLoaded(PropertiesConfiguration.getInclude(), "testClassPath.properties,testEqual.properties", null);
         assertEquals("Layout's load() was called", 0, layout.loadCalls);
         assertFalse("Property was added", conf.containsKey(PropertiesConfiguration.getInclude()));
     }
diff --git a/src/test/java/org/apache/commons/configuration2/TestPropertiesConfigurationLayout.java b/src/test/java/org/apache/commons/configuration2/TestPropertiesConfigurationLayout.java
index 6b63d33..f5912ac 100644
--- a/src/test/java/org/apache/commons/configuration2/TestPropertiesConfigurationLayout.java
+++ b/src/test/java/org/apache/commons/configuration2/TestPropertiesConfigurationLayout.java
@@ -26,6 +26,8 @@
 import java.io.Reader;
 import java.io.StringReader;
 import java.io.StringWriter;
+import java.net.URL;
+import java.util.Deque;
 import java.util.Iterator;
 
 import org.apache.commons.configuration2.convert.DefaultListDelimiterHandler;
@@ -830,12 +832,12 @@
          * load() call on the layout is invoked.
          */
         @Override
-        boolean propertyLoaded(final String key, final String value)
+        boolean propertyLoaded(final String key, final String value, Deque<URL> seenStack)
                 throws ConfigurationException
         {
             if (builder == null)
             {
-                return super.propertyLoaded(key, value);
+                return super.propertyLoaded(key, value, seenStack);
             }
             if (PropertiesConfiguration.getInclude().equals(key))
             {
diff --git a/src/test/resources/include-cyclical-back-to-root.properties b/src/test/resources/include-cyclical-back-to-root.properties
new file mode 100644
index 0000000..71a5b8d
--- /dev/null
+++ b/src/test/resources/include-cyclical-back-to-root.properties
@@ -0,0 +1,16 @@
+#   Licensed to the Apache Software Foundation (ASF) under one or more
+#   contributor license agreements.  See the NOTICE file distributed with
+#   this work for additional information regarding copyright ownership.
+#   The ASF licenses this file to You under the Apache License, Version 2.0
+#   (the "License"); you may not use this file except in compliance with
+#   the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+include = src/test/resources/include-cyclical-root.properties
+keyA = valueA
diff --git a/src/test/resources/include-cyclical-root.properties b/src/test/resources/include-cyclical-root.properties
new file mode 100644
index 0000000..5e18ec4
--- /dev/null
+++ b/src/test/resources/include-cyclical-root.properties
@@ -0,0 +1,16 @@
+#   Licensed to the Apache Software Foundation (ASF) under one or more
+#   contributor license agreements.  See the NOTICE file distributed with
+#   this work for additional information regarding copyright ownership.
+#   The ASF licenses this file to You under the Apache License, Version 2.0
+#   (the "License"); you may not use this file except in compliance with
+#   the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+include = src/test/resources/include-cyclical-step.properties
+keyA = valueA
diff --git a/src/test/resources/include-cyclical-step.properties b/src/test/resources/include-cyclical-step.properties
new file mode 100644
index 0000000..edee5d5
--- /dev/null
+++ b/src/test/resources/include-cyclical-step.properties
@@ -0,0 +1,16 @@
+#   Licensed to the Apache Software Foundation (ASF) under one or more
+#   contributor license agreements.  See the NOTICE file distributed with
+#   this work for additional information regarding copyright ownership.
+#   The ASF licenses this file to You under the Apache License, Version 2.0
+#   (the "License"); you may not use this file except in compliance with
+#   the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+include = src/test/resources/include-cyclical-back-to-root.properties
+keyA = valueA
diff --git a/src/test/resources/include-include-cyclical-reference.properties b/src/test/resources/include-include-cyclical-reference.properties
new file mode 100644
index 0000000..7a5ccf4
--- /dev/null
+++ b/src/test/resources/include-include-cyclical-reference.properties
@@ -0,0 +1,16 @@
+#   Licensed to the Apache Software Foundation (ASF) under one or more
+#   contributor license agreements.  See the NOTICE file distributed with
+#   this work for additional information regarding copyright ownership.
+#   The ASF licenses this file to You under the Apache License, Version 2.0
+#   (the "License"); you may not use this file except in compliance with
+#   the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+include = src/test/resources/include-cyclical-reference.properties
+keyA = valueA