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"); + } }