1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.oscal.lib.profile.resolver.alter;
7   
8   import java.util.Collections;
9   import java.util.EnumMap;
10  import java.util.EnumSet;
11  import java.util.LinkedList;
12  import java.util.List;
13  import java.util.ListIterator;
14  import java.util.Locale;
15  import java.util.Map;
16  import java.util.Set;
17  import java.util.concurrent.ConcurrentHashMap;
18  import java.util.function.Consumer;
19  import java.util.function.Function;
20  import java.util.function.Supplier;
21  
22  import dev.metaschema.core.datatype.markup.MarkupLine;
23  import dev.metaschema.core.util.CollectionUtil;
24  import dev.metaschema.core.util.CustomCollectors;
25  import dev.metaschema.core.util.ObjectUtils;
26  import dev.metaschema.oscal.lib.model.Catalog;
27  import dev.metaschema.oscal.lib.model.CatalogGroup;
28  import dev.metaschema.oscal.lib.model.Control;
29  import dev.metaschema.oscal.lib.model.ControlPart;
30  import dev.metaschema.oscal.lib.model.Link;
31  import dev.metaschema.oscal.lib.model.Parameter;
32  import dev.metaschema.oscal.lib.model.Property;
33  import dev.metaschema.oscal.lib.model.control.catalog.ICatalogVisitor;
34  import dev.metaschema.oscal.lib.profile.resolver.ProfileResolutionEvaluationException;
35  import edu.umd.cs.findbugs.annotations.NonNull;
36  import edu.umd.cs.findbugs.annotations.Nullable;
37  
38  @SuppressWarnings("PMD.CouplingBetweenObjects")
39  public class AddVisitor implements ICatalogVisitor<Boolean, AddVisitor.Context> {
40    public enum TargetType {
41      CONTROL("control", Control.class),
42      PARAM("param", Parameter.class),
43      PART("part", ControlPart.class);
44  
45      @NonNull
46      private static final Map<Class<?>, TargetType> CLASS_TO_TYPE;
47      @NonNull
48      private static final Map<String, TargetType> NAME_TO_TYPE;
49      @NonNull
50      private final String fieldName;
51      @NonNull
52      private final Class<?> clazz;
53  
54      static {
55        {
56          Map<Class<?>, TargetType> map = new ConcurrentHashMap<>();
57          for (TargetType type : values()) {
58            map.put(type.getClazz(), type);
59          }
60          CLASS_TO_TYPE = CollectionUtil.unmodifiableMap(map);
61        }
62  
63        {
64          Map<String, TargetType> map = new ConcurrentHashMap<>();
65          for (TargetType type : values()) {
66            map.put(type.fieldName(), type);
67          }
68          NAME_TO_TYPE = CollectionUtil.unmodifiableMap(map);
69        }
70      }
71  
72      /**
73       * Get the target type associated with the provided {@code clazz}.
74       *
75       * @param clazz
76       *          the class to identify the target type for
77       * @return the associated target type or {@code null} if the class is not
78       *         associated with a target type
79       */
80      @Nullable
81      public static TargetType forClass(@NonNull Class<?> clazz) {
82        Class<?> target = clazz;
83        TargetType retval;
84        // recurse over parent classes to find a match
85        do {
86          retval = CLASS_TO_TYPE.get(target);
87        } while (retval == null && (target = target.getSuperclass()) != null);
88        return retval;
89      }
90  
91      /**
92       * Get the target type associated with the provided field {@code name}.
93       *
94       * @param name
95       *          the field name to identify the target type for
96       * @return the associated target type or {@code null} if the name is not
97       *         associated with a target type
98       */
99      @Nullable
100     public static TargetType forFieldName(@Nullable String name) {
101       return name == null ? null : NAME_TO_TYPE.get(name);
102     }
103 
104     TargetType(@NonNull String fieldName, @NonNull Class<?> clazz) {
105       this.fieldName = fieldName;
106       this.clazz = clazz;
107     }
108 
109     /**
110      * Get the field name associated with the target type.
111      *
112      * @return the name
113      */
114     public String fieldName() {
115       return fieldName;
116     }
117 
118     /**
119      * Get the bound class associated with the target type.
120      *
121      * @return the class
122      */
123     public Class<?> getClazz() {
124       return clazz;
125     }
126   }
127 
128   public enum Position {
129     BEFORE,
130     AFTER,
131     STARTING,
132     ENDING;
133 
134     @NonNull
135     private static final Map<String, Position> NAME_TO_POSITION;
136 
137     static {
138       Map<String, Position> map = new ConcurrentHashMap<>();
139       for (Position position : values()) {
140         map.put(position.name().toLowerCase(Locale.ROOT), position);
141       }
142       NAME_TO_POSITION = CollectionUtil.unmodifiableMap(map);
143     }
144 
145     /**
146      * Get the position associated with the provided {@code name}.
147      *
148      * @param name
149      *          the name to identify the position for
150      * @return the associated position or {@code null} if the name is not associated
151      *         with a position
152      */
153     @Nullable
154     public static Position forName(@Nullable String name) {
155       return name == null ? null : NAME_TO_POSITION.get(name);
156     }
157   }
158 
159   @NonNull
160   private static final AddVisitor INSTANCE = new AddVisitor();
161   private static final Map<TargetType, Set<TargetType>> APPLICABLE_TARGETS;
162 
163   static {
164     APPLICABLE_TARGETS = new EnumMap<>(TargetType.class);
165     APPLICABLE_TARGETS.put(TargetType.CONTROL, Set.of(TargetType.CONTROL, TargetType.PARAM, TargetType.PART));
166     APPLICABLE_TARGETS.put(TargetType.PARAM, Set.of(TargetType.PARAM));
167     APPLICABLE_TARGETS.put(TargetType.PART, Set.of(TargetType.PART));
168   }
169 
170   private static Set<TargetType> getApplicableTypes(@NonNull TargetType type) {
171     return APPLICABLE_TARGETS.getOrDefault(type, CollectionUtil.emptySet());
172   }
173 
174   /**
175    * Apply the add directive.
176    *
177    * @param control
178    *          the control target
179    * @param position
180    *          the position to apply the content or {@code null}
181    * @param byId
182    *          the identifier of the target or {@code null}
183    * @param title
184    *          a title to set
185    * @param params
186    *          parameters to add
187    * @param props
188    *          properties to add
189    * @param links
190    *          links to add
191    * @param parts
192    *          parts to add
193    * @return {@code true} if the modification was made or {@code false} otherwise
194    * @throws ProfileResolutionEvaluationException
195    *           if a processing error occurred during profile resolution
196    */
197   public static boolean add(
198       @NonNull Control control,
199       @Nullable Position position,
200       @Nullable String byId,
201       @Nullable MarkupLine title,
202       @NonNull List<Parameter> params,
203       @NonNull List<Property> props,
204       @NonNull List<Link> links,
205       @NonNull List<ControlPart> parts) {
206     return INSTANCE.visitControl(
207         control,
208         Context.newContext(
209             control,
210             position == null ? Position.ENDING : position,
211             byId,
212             title,
213             params,
214             props,
215             links,
216             parts));
217   }
218 
219   @Override
220   public Boolean visitCatalog(Catalog catalog, Context context) {
221     // not required
222     throw new UnsupportedOperationException("not needed");
223   }
224 
225   @Override
226   public Boolean visitGroup(CatalogGroup group, Context context) {
227     // not required
228     throw new UnsupportedOperationException("not needed");
229   }
230 
231   /**
232    * If the add applies to the current object, then apply the child objects.
233    * <p>
234    * An add applies if:
235    * <ol>
236    * <li>the {@code targetItem} supports all of the children</li>
237    * <li>the context matches if:
238    * <ul>
239    * <li>the target item's id matches the "by-id"; or</li>
240    * <li>the "by-id" is not defined and the target item is the control matching
241    * the target context</li>
242    * </ul>
243    * </li>
244    * </ol>
245    *
246    * @param <T>
247    *          the type of the {@code targetItem}
248    * @param targetItem
249    *          the current target to process
250    * @param titleConsumer
251    *          a consumer to apply a title to or {@code null} if the object has no
252    *          title field
253    * @param paramsSupplier
254    *          a supplier for the child {@link Parameter} collection
255    * @param propsSupplier
256    *          a supplier for the child {@link Property} collection
257    * @param linksSupplier
258    *          a supplier for the child {@link Link} collection
259    * @param partsSupplier
260    *          a supplier for the child {@link ControlPart} collection
261    * @param context
262    *          the add context
263    * @return {@code true} if a modification was made or {@code false} otherwise
264    */
265   private static <T> boolean handleCurrent(
266       @NonNull T targetItem,
267       @Nullable Consumer<MarkupLine> titleConsumer,
268       @Nullable Supplier<? extends List<Parameter>> paramsSupplier,
269       @Nullable Supplier<? extends List<Property>> propsSupplier,
270       @Nullable Supplier<? extends List<Link>> linksSupplier,
271       @Nullable Supplier<? extends List<ControlPart>> partsSupplier,
272       @NonNull Context context) {
273     boolean retval = false;
274     Position position = context.getPosition();
275     if (context.appliesTo(targetItem) && !context.isSequenceTargeted(targetItem)) {
276       // the target item is the target of the add
277       MarkupLine newTitle = context.getTitle();
278       if (newTitle != null) {
279         assert titleConsumer != null;
280         titleConsumer.accept(newTitle);
281       }
282 
283       handleCollection(position, context.getParams(), paramsSupplier);
284       handleCollection(position, context.getProps(), propsSupplier);
285       handleCollection(position, context.getLinks(), linksSupplier);
286       handleCollection(position, context.getParts(), partsSupplier);
287       retval = true;
288     }
289     return retval;
290   }
291 
292   private static <T> void handleCollection(
293       @NonNull Position position,
294       @NonNull List<T> newItems,
295       @Nullable Supplier<? extends List<T>> originalCollectionSupplier) {
296     if (originalCollectionSupplier != null) {
297       List<T> oldItems = originalCollectionSupplier.get();
298       if (!newItems.isEmpty()) {
299         if (Position.STARTING.equals(position)) {
300           oldItems.addAll(0, newItems);
301         } else { // ENDING
302           oldItems.addAll(newItems);
303         }
304       }
305     }
306   }
307 
308   // private static <T> void handleChild(
309   // @NonNull TargetType itemType,
310   // @NonNull Supplier<? extends List<T>> collectionSupplier,
311   // @Nullable Consumer<T> handler,
312   // @NonNull Context context) {
313   // boolean handleChildren = !Collections.disjoint(context.getTargetItemTypes(),
314   // getApplicableTypes(itemType));
315   // if (handleChildren && handler != null) {
316   // // if the child item type is applicable and there is a handler, iterate over
317   // children
318   // Iterator<T> iter = collectionSupplier.get().iterator();
319   // while (iter.hasNext()) {
320   // T item = iter.next();
321   // if (item != null) {
322   // handler.accept(item);
323   // }
324   // }
325   // }
326   // }
327 
328   @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity" })
329   private static <T> boolean handleChild(
330       @NonNull TargetType itemType,
331       @NonNull Supplier<? extends List<T>> originalCollectionSupplier,
332       @NonNull Supplier<? extends List<T>> newItemsSupplier,
333       @Nullable Function<T, Boolean> handler,
334       @NonNull Context context) {
335 
336     // determine if this child type can match
337     boolean isItemTypeMatch = context.isMatchingType(itemType);
338 
339     Set<TargetType> applicableTypes = getApplicableTypes(itemType);
340     boolean descendChild = handler != null && !Collections.disjoint(context.getTargetItemTypes(), applicableTypes);
341 
342     boolean retval = false;
343     if (isItemTypeMatch || descendChild) {
344       // if the item type is applicable, attempt to match by id
345       List<T> collection = originalCollectionSupplier.get();
346       ListIterator<T> iter = collection.listIterator();
347       boolean deferred = false;
348       while (iter.hasNext()) {
349         T item = ObjectUtils.requireNonNull(iter.next());
350 
351         if (isItemTypeMatch && context.appliesTo(item) && context.isSequenceTargeted(item)) {
352           // if id match, inject the new items into the collection
353           switch (context.getPosition()) {
354           case AFTER: {
355             newItemsSupplier.get().forEach(iter::add);
356             retval = true;
357             break;
358           }
359           case BEFORE: {
360             iter.previous();
361             List<T> adds = newItemsSupplier.get();
362             adds.forEach(iter::add);
363             item = iter.next();
364             retval = true;
365             break;
366           }
367           case STARTING:
368           case ENDING:
369             deferred = true;
370             break;
371           default:
372             throw new UnsupportedOperationException(context.getPosition().name().toLowerCase(Locale.ROOT));
373           }
374         }
375 
376         if (descendChild) {
377           assert handler != null;
378 
379           // handle child items since they are applicable to the search criteria
380           retval = retval || handler.apply(item);
381         }
382       }
383 
384       if (deferred) {
385         List<T> newItems = newItemsSupplier.get();
386         if (Position.ENDING.equals(context.getPosition())) {
387           collection.addAll(newItems);
388           retval = true;
389         } else if (Position.STARTING.equals(context.getPosition())) {
390           collection.addAll(0, newItems);
391           retval = true;
392         }
393       }
394     }
395     return retval;
396   }
397 
398   @Override
399   public Boolean visitControl(Control control, Context context) {
400     assert context != null;
401 
402     boolean retval = handleCurrent(
403         control,
404         control::setTitle,
405         control::getParams,
406         control::getProps,
407         control::getLinks,
408         control::getParts,
409         context);
410 
411     // visit params
412     retval = retval || handleChild(
413         TargetType.PARAM,
414         control::getParams,
415         context::getParams,
416         child -> visitParameter(ObjectUtils.notNull(child), context),
417         context);
418 
419     // visit parts
420     retval = retval || handleChild(
421         TargetType.PART,
422         control::getParts,
423         context::getParts,
424         child -> visitPart(child, context),
425         context);
426 
427     // visit control children
428     for (Control childControl : CollectionUtil.listOrEmpty(control.getControls())) {
429       Set<TargetType> applicableTypes = getApplicableTypes(TargetType.CONTROL);
430       if (!Collections.disjoint(context.getTargetItemTypes(), applicableTypes)) {
431         retval = retval || visitControl(ObjectUtils.requireNonNull(childControl), context);
432       }
433     }
434     return retval;
435   }
436 
437   @Override
438   public Boolean visitParameter(Parameter parameter, Context context) {
439     assert context != null;
440 
441     return handleCurrent(
442         parameter,
443         null,
444         null,
445         parameter::getProps,
446         parameter::getLinks,
447         null,
448         context);
449   }
450 
451   /**
452    * Visit the control part.
453    *
454    * @param part
455    *          the bound part object
456    * @param context
457    *          the visitor context
458    * @return {@code true} if the removal was applied or {@code false} otherwise
459    */
460   public boolean visitPart(ControlPart part, Context context) {
461     assert context != null;
462 
463     boolean retval = handleCurrent(
464         part,
465         null,
466         null,
467         part::getProps,
468         part::getLinks,
469         part::getParts,
470         context);
471 
472     return retval || handleChild(
473         TargetType.PART,
474         part::getParts,
475         context::getParts,
476         child -> visitPart(child, context),
477         context);
478   }
479 
480   static final class Context {
481     @NonNull
482     private static final Set<TargetType> TITLE_TYPES = ObjectUtils.notNull(
483         Set.of(TargetType.CONTROL, TargetType.PART));
484     @NonNull
485     private static final Set<TargetType> PARAM_TYPES = ObjectUtils.notNull(
486         Set.of(TargetType.CONTROL, TargetType.PARAM));
487     @NonNull
488     private static final Set<TargetType> PROP_TYPES = ObjectUtils.notNull(
489         Set.of(TargetType.CONTROL, TargetType.PARAM, TargetType.PART));
490     @NonNull
491     private static final Set<TargetType> LINK_TYPES = ObjectUtils.notNull(
492         Set.of(TargetType.CONTROL, TargetType.PARAM, TargetType.PART));
493     @NonNull
494     private static final Set<TargetType> PART_TYPES = ObjectUtils.notNull(
495         Set.of(TargetType.CONTROL, TargetType.PART));
496 
497     @NonNull
498     private final Control control;
499     @NonNull
500     private final Position position;
501     @Nullable
502     private final String byId;
503     @Nullable
504     private final MarkupLine title;
505     @NonNull
506     private final List<Parameter> params;
507     @NonNull
508     private final List<Property> props;
509     @NonNull
510     private final List<Link> links;
511     @NonNull
512     private final List<ControlPart> parts;
513     @NonNull
514     private final Set<TargetType> targetItemTypes;
515 
516     @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity", "PMD.NPathComplexity" })
517     public static Context newContext(
518         @NonNull Control control,
519         @NonNull Position position,
520         @Nullable String byId,
521         @Nullable MarkupLine title,
522         @NonNull List<Parameter> params,
523         @NonNull List<Property> props,
524         @NonNull List<Link> links,
525         @NonNull List<ControlPart> parts) {
526       Set<TargetType> targetItemTypes = ObjectUtils.notNull(EnumSet.allOf(TargetType.class));
527       List<String> additionObjects = new LinkedList<>();
528 
529       boolean sequenceTarget = true;
530       if (title != null) {
531         targetItemTypes.retainAll(TITLE_TYPES);
532         additionObjects.add("title");
533         sequenceTarget = false;
534       }
535 
536       if (!params.isEmpty()) {
537         targetItemTypes.retainAll(PARAM_TYPES);
538         additionObjects.add("param");
539       }
540 
541       if (!props.isEmpty()) {
542         targetItemTypes.retainAll(PROP_TYPES);
543         additionObjects.add("prop");
544         sequenceTarget = false;
545       }
546 
547       if (!links.isEmpty()) {
548         targetItemTypes.retainAll(LINK_TYPES);
549         additionObjects.add("link");
550         sequenceTarget = false;
551       }
552 
553       if (!parts.isEmpty()) {
554         targetItemTypes.retainAll(PART_TYPES);
555         additionObjects.add("part");
556       }
557 
558       if (Position.BEFORE.equals(position) || Position.AFTER.equals(position)) {
559         if (!sequenceTarget) {
560           throw new ProfileResolutionEvaluationException(
561               "When using position before or after, one collection of parameters or parts can be specified."
562                   + " Other additions must not be used.");
563         }
564         if (!params.isEmpty() && parts.isEmpty()) {
565           targetItemTypes.retainAll(Set.of(TargetType.PARAM));
566         } else if (!parts.isEmpty() && params.isEmpty()) {
567           targetItemTypes.retainAll(Set.of(TargetType.PART));
568         } else {
569           throw new ProfileResolutionEvaluationException(
570               "When using position before or after, only one collection of parameters or parts can be specified.");
571         }
572       }
573 
574       if (targetItemTypes.isEmpty()) {
575         throw new ProfileResolutionEvaluationException("No parent object supports the requested objects to add: " +
576             additionObjects.stream().collect(CustomCollectors.joiningWithOxfordComma("or")));
577       }
578 
579       return new Context(
580           control,
581           position,
582           byId,
583           title,
584           params,
585           props,
586           links,
587           parts,
588           targetItemTypes);
589     }
590 
591     private Context(
592         @NonNull Control control,
593         @NonNull Position position,
594         @Nullable String byId,
595         @Nullable MarkupLine title,
596         @NonNull List<Parameter> params,
597         @NonNull List<Property> props,
598         @NonNull List<Link> links,
599         @NonNull List<ControlPart> parts,
600         @NonNull Set<TargetType> targetItemTypes) {
601       this.control = control;
602       this.position = position;
603       this.byId = byId;
604       this.title = title;
605       this.params = params;
606       this.props = props;
607       this.links = links;
608       this.parts = parts;
609       this.targetItemTypes = CollectionUtil.unmodifiableSet(targetItemTypes);
610     }
611 
612     @NonNull
613     private Control getControl() {
614       return control;
615     }
616 
617     @NonNull
618     private Position getPosition() {
619       return position;
620     }
621 
622     @Nullable
623     private String getById() {
624       return byId;
625     }
626 
627     @Nullable
628     private MarkupLine getTitle() {
629       return title;
630     }
631 
632     @NonNull
633     private List<Parameter> getParams() {
634       return params;
635     }
636 
637     @NonNull
638     private List<Property> getProps() {
639       return props;
640     }
641 
642     @NonNull
643     private List<Link> getLinks() {
644       return links;
645     }
646 
647     @NonNull
648     private List<ControlPart> getParts() {
649       return parts;
650     }
651 
652     @NonNull
653     private Set<TargetType> getTargetItemTypes() {
654       return targetItemTypes;
655     }
656 
657     private boolean isMatchingType(@NonNull TargetType type) {
658       return getTargetItemTypes().contains(type);
659     }
660 
661     private <T> boolean isSequenceTargeted(T targetItem) {
662       TargetType objectType = TargetType.forClass(targetItem.getClass());
663       return (Position.BEFORE.equals(position) || Position.AFTER.equals(position))
664           && (TargetType.PARAM.equals(objectType) && isMatchingType(TargetType.PARAM)
665               || TargetType.PART.equals(objectType) && isMatchingType(TargetType.PART));
666     }
667 
668     /**
669      * Determine if the provided {@code obj} is the target of the add.
670      *
671      * @param obj
672      *          the current object
673      * @return {@code true} if the current object applies or {@code false} otherwise
674      */
675     private boolean appliesTo(@NonNull Object obj) {
676       TargetType objectType = TargetType.forClass(obj.getClass());
677 
678       boolean retval = objectType != null && isMatchingType(objectType);
679       if (retval) {
680         assert objectType != null;
681 
682         // check other criteria
683         String actualId = null;
684         switch (objectType) {
685         case CONTROL: {
686           Control control = (Control) obj;
687           actualId = control.getId();
688           break;
689         }
690         case PARAM: {
691           Parameter param = (Parameter) obj;
692           actualId = param.getId();
693           break;
694         }
695         case PART: {
696           ControlPart part = (ControlPart) obj;
697           String partId = part.getId();
698           if (part.getId() != null) {
699             actualId = partId;
700           }
701           break;
702         }
703         default:
704           throw new UnsupportedOperationException(objectType.fieldName());
705         }
706 
707         String byId = getById();
708         if (getById() == null && TargetType.CONTROL.equals(objectType)) {
709           retval = getControl().equals(obj);
710         } else {
711           retval = byId != null && byId.equals(actualId);
712         }
713       }
714       return retval;
715     }
716   }
717 }