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