diff --git a/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/merge/FlatteningStructuringVisitor.java b/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/merge/FlatteningStructuringVisitor.java
index 5cac8f47..322bd5f1 100644
--- a/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/merge/FlatteningStructuringVisitor.java
+++ b/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/merge/FlatteningStructuringVisitor.java
@@ -31,6 +31,7 @@
import gov.nist.secauto.metaschema.model.common.metapath.item.IRootAssemblyNodeItem;
import gov.nist.secauto.metaschema.model.common.util.ObjectUtils;
import gov.nist.secauto.oscal.lib.model.BackMatter.Resource;
+import gov.nist.secauto.oscal.lib.model.Catalog;
import gov.nist.secauto.oscal.lib.model.CatalogGroup;
import gov.nist.secauto.oscal.lib.model.Control;
import gov.nist.secauto.oscal.lib.model.ControlPart;
@@ -237,6 +238,11 @@ public DefaultResult visitControl(IRequiredValueModelNodeItem item, DefaultResul
if (parent.getValue() instanceof Control && SelectionStatus.SELECTED.equals(index.getSelectionStatus(parent))) {
retval.removeControl(control);
}
+ // Cancel promotion of this control if control is already at the top level (control's parent is Catalog)
+ // If already at top level, then promotion is not needed because it was added by Import class
+ if (parent.getValue() instanceof Catalog) {
+ retval.removeControl(control);
+ }
} else {
// remove this control and promote any needed children
diff --git a/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/selection/FilterNonSelectedVisitor.java b/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/selection/FilterNonSelectedVisitor.java
index 022a8a51..d4f07ea5 100644
--- a/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/selection/FilterNonSelectedVisitor.java
+++ b/src/main/java/gov/nist/secauto/oscal/lib/profile/resolver/selection/FilterNonSelectedVisitor.java
@@ -207,8 +207,11 @@ public DefaultResult visitControl(
control.setId(entity.getIdentifier());
if (!SelectionStatus.SELECTED.equals(index.getSelectionStatus(parent))) {
- // promote this control
- retval.promoteControl(control);
+ // promote this control if control is not already at the top level (control's parent is Catalog)
+ // If already at top level, then promotion is not needed because it was added by Import class
+ if (!(parent.getValue() instanceof Catalog)) {
+ retval.promoteControl(control);
+ }
}
childResult.applyTo(control);
} else {
diff --git a/src/test/java/gov/nist/secauto/oscal/lib/profile/resolver/ProfileResolutionTests.java b/src/test/java/gov/nist/secauto/oscal/lib/profile/resolver/ProfileResolutionTests.java
index 89a406a1..0e73b58a 100644
--- a/src/test/java/gov/nist/secauto/oscal/lib/profile/resolver/ProfileResolutionTests.java
+++ b/src/test/java/gov/nist/secauto/oscal/lib/profile/resolver/ProfileResolutionTests.java
@@ -38,6 +38,7 @@
import gov.nist.secauto.metaschema.model.common.metapath.StaticContext;
import gov.nist.secauto.oscal.lib.OscalBindingContext;
import gov.nist.secauto.oscal.lib.model.Catalog;
+import gov.nist.secauto.oscal.lib.model.Profile;
import gov.nist.secauto.oscal.lib.profile.resolver.selection.ImportCycleException;
import net.sf.saxon.s9api.Processor;
@@ -66,6 +67,8 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.ZoneOffset;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
@@ -256,4 +259,49 @@ void testArsModerateProfile() throws IOException, ProfileResolutionException, UR
assertNotNull(resolvedCatalog);
}
+
+ // Test for https://github.com/usnistgov/liboscal-java/issues/360
+ // The issue:
+ // 1. Profile imports a catalog using
+ // 2. The catalog contains only controls at the top level, no groups.
+ // If both criteria are met, controls were added multiple times to the resolved
+ // profile because of the promoting controls process, which checks if controls
+ // need to be added again.
+ @Test
+ void testProfileResolverIssue360() throws IOException, ProfileResolutionException, URISyntaxException {
+ // Import profile
+ Path profilePath = Paths.get(JUNIT_TEST_PATH, "content/issue360-profile.xml");
+ assert profilePath != null;
+
+ // Resolve the profile
+ Catalog resolvedCatalog = resolveProfile(profilePath);
+ assertNotNull(resolvedCatalog);
+
+ // Write resolved profile to XML string
+ ISerializer serializer = OscalBindingContext.instance().newSerializer(Format.XML, Catalog.class);
+ String profileXMLString;
+ try (StringWriter writer = new StringWriter()) {
+ serializer.serialize(resolvedCatalog, writer);
+ profileXMLString = writer.toString();
+ }
+
+ // In XML string, count occurrences of control a1
+ Pattern controlAPattern = Pattern.compile("]*id=\"a1\"[^>]*>");
+ Matcher matcherA = controlAPattern.matcher(profileXMLString);
+ int countA = 0;
+ while (matcherA.find()) {
+ countA++;
+ }
+
+ // In XML string, count occurrences of control b1
+ Pattern controlBPattern = Pattern.compile("]*id=\"b1\"[^>]*>");
+ Matcher matcherB = controlBPattern.matcher(profileXMLString);
+ int countB = 0;
+ while (matcherB.find()) {
+ countB++;
+ }
+
+ assertEquals(1, countA, "Control a1 should appear only once in resolved profile");
+ assertEquals(1, countB, "Control b1 should appear only once in resolved profile");
+ }
}
diff --git a/src/test/resources/content/issue360-catalog.xml b/src/test/resources/content/issue360-catalog.xml
new file mode 100644
index 00000000..25aef041
--- /dev/null
+++ b/src/test/resources/content/issue360-catalog.xml
@@ -0,0 +1,28 @@
+
+
+
+ Issue 360 Catalog
+ 2026-08-17T18:19:00.817580161Z
+ 1.0.0
+ 1.2.2
+
+
+ Control A1
+
+
+
+
+
+