From eac21cfc3b4accb3c3bc98be8ddc2b8f76a6815a Mon Sep 17 00:00:00 2001
From: selenaxiao-nist <142448406+selenaxiao-nist@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:51:00 -0400
Subject: [PATCH 1/4] Control promotion handle duplication, check if control is
already at top level
---
.../resolver/selection/FilterNonSelectedVisitor.java | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
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 {
From 2838fcf7ee8fc0b0d3bfa1e1d50ad008af319cef Mon Sep 17 00:00:00 2001
From: selenaxiao-nist <142448406+selenaxiao-nist@users.noreply.github.com>
Date: Mon, 17 Aug 2026 10:08:28 -0400
Subject: [PATCH 2/4] Control promotion handle duplication, check if control is
already at top level
---
.../resolver/merge/FlatteningStructuringVisitor.java | 6 ++++++
1 file changed, 6 insertions(+)
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
From defc495e959333a16675ac7429b7021ce8713f58 Mon Sep 17 00:00:00 2001
From: selenaxiao-nist <142448406+selenaxiao-nist@users.noreply.github.com>
Date: Mon, 17 Aug 2026 14:08:17 -0400
Subject: [PATCH 3/4] Test files
---
.../resources/content/issue360-catalog.xml | 28 +++++++++++++++++++
.../resources/content/issue360-profile.xml | 15 ++++++++++
2 files changed, 43 insertions(+)
create mode 100644 src/test/resources/content/issue360-catalog.xml
create mode 100644 src/test/resources/content/issue360-profile.xml
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
+
+
+
+
+
+
+ A1 aaaaa aaaaaaaaaa
+
+
+
+
+ Control B1
+
+
+
B1 bbbb bbbbbbb.
+
+
+
diff --git a/src/test/resources/content/issue360-profile.xml b/src/test/resources/content/issue360-profile.xml
new file mode 100644
index 00000000..96456c11
--- /dev/null
+++ b/src/test/resources/content/issue360-profile.xml
@@ -0,0 +1,15 @@
+
+
+
+ Issue 360 Profile
+ 2026-08-17T16:15:16-04:00
+ 1.0.0
+ 1.2.2
+
+
+
+
+
From 3fdc08d01481015694f1ae4d3582c05d3e66b7c3 Mon Sep 17 00:00:00 2001
From: selenaxiao-nist <142448406+selenaxiao-nist@users.noreply.github.com>
Date: Tue, 18 Aug 2026 10:41:06 -0400
Subject: [PATCH 4/4] Test the profile resolver fix
---
.../resolver/ProfileResolutionTests.java | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
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");
+ }
}