001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.oscal.tools.cli.core.commands;
007
008import org.apache.commons.cli.CommandLine;
009import org.apache.commons.cli.Option;
010
011import java.io.IOException;
012import java.io.PrintStream;
013import java.net.URI;
014import java.net.URISyntaxException;
015import java.nio.file.Path;
016import java.util.Collection;
017import java.util.List;
018
019import dev.metaschema.cli.commands.MetaschemaCommands;
020import dev.metaschema.cli.processor.CallingContext;
021import dev.metaschema.cli.processor.ExitCode;
022import dev.metaschema.cli.processor.command.AbstractTerminalCommand;
023import dev.metaschema.cli.processor.command.CommandExecutionException;
024import dev.metaschema.cli.processor.command.ExtraArgument;
025import dev.metaschema.cli.processor.command.ICommandExecutor;
026import dev.metaschema.core.metapath.DynamicContext;
027import dev.metaschema.core.metapath.item.node.IDocumentNodeItem;
028import dev.metaschema.core.metapath.item.node.INodeItem;
029import dev.metaschema.core.util.ObjectUtils;
030import dev.metaschema.core.util.UriUtils;
031import dev.metaschema.databind.IBindingContext;
032import dev.metaschema.databind.io.DeserializationFeature;
033import dev.metaschema.databind.io.Format;
034import dev.metaschema.databind.io.IBoundLoader;
035import dev.metaschema.databind.io.ISerializer;
036import dev.metaschema.oscal.lib.OscalBindingContext;
037import dev.metaschema.oscal.lib.model.Catalog;
038import dev.metaschema.oscal.lib.model.Profile;
039import dev.metaschema.oscal.lib.profile.resolver.ProfileResolutionException;
040import dev.metaschema.oscal.lib.profile.resolver.ProfileResolver;
041import dev.metaschema.oscal.tools.cli.core.utils.PrettyPrinter;
042import edu.umd.cs.findbugs.annotations.NonNull;
043
044/**
045 * A command implementation supporting the resolution of an OSCAL profile.
046 */
047public abstract class AbstractResolveCommand
048    extends AbstractTerminalCommand {
049  @NonNull
050  private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
051      ExtraArgument.newInstance("URI to resolve", true),
052      ExtraArgument.newInstance("destination file", false)));
053  private static final Option RELATIVE_TO = Option.builder()
054      .longOpt("relative-to")
055      .desc("Generate URI references relative to this resource")
056      .hasArg()
057      .build();
058  private static final Option PRETTY_PRINT_OPTION = Option.builder()
059      .longOpt("pretty-print")
060      .desc("Enable pretty-printing of the output for better readability")
061      .build();
062
063  @NonNull
064  private static final List<Option> OPTIONS = ObjectUtils.notNull(
065      List.of(
066          MetaschemaCommands.AS_FORMAT_OPTION,
067          MetaschemaCommands.TO_OPTION,
068          MetaschemaCommands.OVERWRITE_OPTION,
069          RELATIVE_TO,
070          PRETTY_PRINT_OPTION));
071
072  @Override
073  public String getDescription() {
074    return "Resolve the specified OSCAL Profile";
075  }
076
077  @Override
078  public Collection<? extends Option> gatherOptions() {
079    return OPTIONS;
080  }
081
082  @Override
083  public List<ExtraArgument> getExtraArguments() {
084    return EXTRA_ARGUMENTS;
085  }
086
087  @SuppressWarnings({
088      "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity", // reasonable
089      "PMD.PreserveStackTrace" // intended
090  })
091
092  @Override
093  public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) {
094    return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand);
095  }
096
097  /**
098   * Process the command line arguments and execute the profile resolution
099   * operation.
100   *
101   * @param callingContext
102   *          the context information for the execution
103   * @param cmdLine
104   *          the parsed command line details
105   * @throws CommandExecutionException
106   *           if an error occurred while determining the source format
107   */
108  @SuppressWarnings({
109      "PMD.OnlyOneReturn", // readability
110      "PMD.CyclomaticComplexity"
111  })
112  protected void executeCommand(
113      @NonNull CallingContext callingContext,
114      @NonNull CommandLine cmdLine) throws CommandExecutionException {
115    List<String> extraArgs = cmdLine.getArgList();
116
117    URI source = MetaschemaCommands.handleSource(
118        ObjectUtils.requireNonNull(extraArgs.get(0)),
119        ObjectUtils.notNull(getCurrentWorkingDirectory().toUri()));
120
121    IBindingContext bindingContext = OscalBindingContext.instance();
122    IBoundLoader loader = bindingContext.newBoundLoader();
123    loader.disableFeature(DeserializationFeature.DESERIALIZE_VALIDATE_CONSTRAINTS);
124
125    // attempt to determine the format
126    Format asFormat = MetaschemaCommands.determineSourceFormat(
127        cmdLine,
128        MetaschemaCommands.AS_FORMAT_OPTION,
129        loader,
130        source);
131
132    IDocumentNodeItem document;
133    try {
134      document = loader.loadAsNodeItem(asFormat, source);
135    } catch (IOException ex) {
136      throw new CommandExecutionException(
137          ExitCode.IO_ERROR,
138          String.format("Unable to load content '%s'. %s",
139              source,
140              ex.getMessage()),
141          ex);
142    }
143
144    Object object = document.getValue();
145    if (object == null) {
146      throw new CommandExecutionException(
147          ExitCode.INVALID_ARGUMENTS,
148          String.format("The source document '%s' contained no data.", source));
149    }
150
151    if (object instanceof Catalog) {
152      // this is a catalog
153      throw new CommandExecutionException(
154          ExitCode.OK,
155          String.format("The source '%s' is already a catalog.", source));
156    }
157
158    if (!(object instanceof Profile)) {
159      // this is something else
160      throw new CommandExecutionException(
161          ExitCode.INVALID_ARGUMENTS,
162          String.format("The source '%s' is not a profile.", source));
163    }
164
165    Path destination = null;
166    if (extraArgs.size() > 1) {
167      destination = MetaschemaCommands.handleDestination(ObjectUtils.requireNonNull(extraArgs.get(1)), cmdLine);
168    }
169
170    URI relativeTo;
171    if (cmdLine.hasOption(RELATIVE_TO)) {
172      relativeTo = getCurrentWorkingDirectory().toUri().resolve(cmdLine.getOptionValue(RELATIVE_TO));
173    } else {
174      relativeTo = document.getDocumentUri();
175    }
176
177    // this is a profile
178    DynamicContext dynamicContext = new DynamicContext(document.getStaticContext());
179    dynamicContext.setDocumentLoader(loader);
180    ProfileResolver resolver = new ProfileResolver(
181        dynamicContext,
182        (uri, src) -> {
183          try {
184            return UriUtils.relativize(relativeTo, src.resolve(uri), true);
185          } catch (URISyntaxException ex) {
186            throw new IllegalArgumentException(ex);
187          }
188        });
189
190    IDocumentNodeItem resolvedProfile;
191    try {
192      resolvedProfile = resolver.resolve(document);
193    } catch (IOException | ProfileResolutionException ex) {
194      throw new CommandExecutionException(
195          ExitCode.PROCESSING_ERROR,
196          String.format("Cmd: Unable to resolve profile '%s'. %s", document.getDocumentUri(), ex.getMessage()),
197          ex);
198    }
199
200    // DefaultConstraintValidator validator = new
201    // DefaultConstraintValidator(dynamicContext);
202    // ((IBoundXdmNodeItem)resolvedProfile).validate(validator);
203    // validator.finalizeValidation();
204
205    Format toFormat = MetaschemaCommands.getFormat(cmdLine, MetaschemaCommands.TO_OPTION);
206    boolean prettyPrint = cmdLine.hasOption(PRETTY_PRINT_OPTION);
207    ISerializer<Catalog> serializer = bindingContext.newSerializer(toFormat, Catalog.class);
208    try {
209      if (destination == null) {
210        @SuppressWarnings({ "resource", "PMD.CloseResource" })
211        PrintStream stdOut = ObjectUtils.notNull(System.out);
212        serializer.serialize((Catalog) INodeItem.toValue(resolvedProfile), stdOut);
213      } else {
214        serializer.serialize((Catalog) INodeItem.toValue(resolvedProfile), destination);
215        if (prettyPrint) {
216          prettyPrintOutput(destination, toFormat);
217        }
218      }
219    } catch (IOException ex) {
220      throw new CommandExecutionException(ExitCode.IO_ERROR, ex);
221    }
222  }
223
224  /**
225   * Pretty-print the output file based on the specified format.
226   * <p>
227   * This feature was originally contributed by Mahesh Kumar Gaddam (ermahesh) in
228   * <a href="https://github.com/usnistgov/oscal-cli/pull/295">PR #295</a>.
229   * </p>
230   *
231   * @param destination
232   *          the path to the output file
233   * @param toFormat
234   *          the format of the output file
235   * @throws CommandExecutionException
236   *           if pretty-printing fails
237   */
238  @SuppressWarnings("PMD.PreserveStackTrace")
239  private void prettyPrintOutput(@NonNull Path destination, @NonNull Format toFormat)
240      throws CommandExecutionException {
241    try {
242      switch (toFormat) {
243      case JSON:
244        PrettyPrinter.prettyPrintJson(destination.toFile());
245        break;
246      case YAML:
247        PrettyPrinter.prettyPrintYaml(destination.toFile());
248        break;
249      case XML:
250        PrettyPrinter.prettyPrintXml(destination.toFile());
251        break;
252      default:
253        // do nothing for unknown formats
254        break;
255      }
256    } catch (Exception ex) {
257      throw new CommandExecutionException(
258          ExitCode.PROCESSING_ERROR,
259          String.format("Pretty-printing failed: %s", ex.getMessage()),
260          ex);
261    }
262  }
263}