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 + + + + + +

+ 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 + + + + +