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.processor.CLIProcessor.CallingContext;
009import gov.nist.secauto.metaschema.cli.processor.ExitCode;
010import gov.nist.secauto.metaschema.cli.processor.ExitStatus;
011import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException;
012import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
013import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand;
014import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument;
015import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
016import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
017import gov.nist.secauto.metaschema.core.metapath.DynamicContext;
018import gov.nist.secauto.metaschema.core.metapath.item.node.IDocumentNodeItem;
019import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem;
020import gov.nist.secauto.metaschema.core.util.CustomCollectors;
021import gov.nist.secauto.metaschema.core.util.ObjectUtils;
022import gov.nist.secauto.metaschema.databind.io.DeserializationFeature;
023import gov.nist.secauto.metaschema.databind.io.Format;
024import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
025import gov.nist.secauto.metaschema.databind.io.ISerializer;
026import gov.nist.secauto.oscal.lib.OscalBindingContext;
027import gov.nist.secauto.oscal.lib.model.Catalog;
028import gov.nist.secauto.oscal.lib.model.Profile;
029import gov.nist.secauto.oscal.lib.profile.resolver.ProfileResolutionException;
030import gov.nist.secauto.oscal.lib.profile.resolver.ProfileResolver;
031
032import org.apache.commons.cli.CommandLine;
033import org.apache.commons.cli.Option;
034
035import java.io.File;
036import java.io.FileNotFoundException;
037import java.io.IOException;
038import java.io.PrintStream;
039import java.nio.file.Files;
040import java.nio.file.Path;
041import java.nio.file.Paths;
042import java.util.Arrays;
043import java.util.Collection;
044import java.util.List;
045import java.util.Locale;
046
047import edu.umd.cs.findbugs.annotations.NonNull;
048
049public abstract class AbstractResolveCommand
050    extends AbstractTerminalCommand {
051  @NonNull
052  private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
053      new DefaultExtraArgument("file to resolve", true),
054      new DefaultExtraArgument("destination file", false)));
055  @NonNull
056  private static final Option AS_OPTION = ObjectUtils.notNull(
057      Option.builder()
058          .longOpt("as")
059          .hasArg()
060          .argName("FORMAT")
061          .desc("source format: xml, json, or yaml")
062          .build());
063  @NonNull
064  private static final Option TO_OPTION = ObjectUtils.notNull(
065      Option.builder()
066          .longOpt("to")
067          .required()
068          .hasArg().argName("FORMAT")
069          .desc("convert to format: xml, json, or yaml")
070          .build());
071  @NonNull
072  private static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
073      Option.builder()
074          .longOpt("overwrite")
075          .desc("overwrite the destination if it exists")
076          .build());
077  @NonNull
078  private static final List<Option> OPTIONS = ObjectUtils.notNull(
079      List.of(
080          AS_OPTION,
081          TO_OPTION,
082          OVERWRITE_OPTION));
083
084  @Override
085  public String getDescription() {
086    return "Resolve the specified OSCAL Profile";
087  }
088
089  @Override
090  public Collection<? extends Option> gatherOptions() {
091    return OPTIONS;
092  }
093
094  @Override
095  public List<ExtraArgument> getExtraArguments() {
096    return EXTRA_ARGUMENTS;
097  }
098
099  @SuppressWarnings({
100      "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity", // reasonable
101      "PMD.PreserveStackTrace" // intended
102  })
103  @Override
104  public void validateOptions(CallingContext callingContext, CommandLine cmdLine) throws InvalidArgumentException {
105    if (cmdLine.hasOption(AS_OPTION)) {
106      try {
107        String toFormatText = cmdLine.getOptionValue(AS_OPTION);
108        Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
109      } catch (IllegalArgumentException ex) {
110        InvalidArgumentException newEx = new InvalidArgumentException(
111            String.format("Invalid '%s' argument. The format must be one of: %s.",
112                OptionUtils.toArgument(AS_OPTION),
113                Arrays.asList(Format.values()).stream()
114                    .map(Enum::name)
115                    .collect(CustomCollectors.joiningWithOxfordComma("and"))));
116        newEx.setOption(AS_OPTION);
117        newEx.addSuppressed(ex);
118        throw newEx;
119      }
120    }
121
122    if (cmdLine.hasOption(TO_OPTION)) {
123      try {
124        String toFormatText = cmdLine.getOptionValue(TO_OPTION);
125        Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
126      } catch (IllegalArgumentException ex) {
127        InvalidArgumentException newEx
128            = new InvalidArgumentException("Invalid '--to' argument. The format must be one of: "
129                + Arrays.asList(Format.values()).stream()
130                    .map(Enum::name)
131                    .collect(CustomCollectors.joiningWithOxfordComma("and")));
132        newEx.setOption(AS_OPTION);
133        newEx.addSuppressed(ex);
134        throw newEx;
135      }
136    }
137
138    List<String> extraArgs = cmdLine.getArgList();
139    if (extraArgs.isEmpty()) {
140      throw new InvalidArgumentException("The source to resolve must be provided.");
141    }
142
143    File source = new File(extraArgs.get(0));
144    if (!source.exists()) {
145      throw new InvalidArgumentException("The provided source '" + source.getPath() + "' does not exist.");
146    }
147    if (!source.canRead()) {
148      throw new InvalidArgumentException("The provided source '" + source.getPath() + "' is not readable.");
149    }
150  }
151
152  @Override
153  public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) {
154    return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand);
155  }
156
157  @SuppressWarnings({
158      "PMD.OnlyOneReturn", // readability
159      "unused"
160  })
161  protected ExitStatus executeCommand(
162      @NonNull CallingContext callingContext,
163      @NonNull CommandLine cmdLine) {
164    List<String> extraArgs = cmdLine.getArgList();
165    Path source = resolvePathAgainstCWD(ObjectUtils.notNull(Paths.get(extraArgs.get(0))));
166
167    IBoundLoader loader = OscalBindingContext.instance().newBoundLoader();
168    loader.disableFeature(DeserializationFeature.DESERIALIZE_VALIDATE_CONSTRAINTS);
169
170    Format asFormat;
171    // attempt to determine the format
172    if (cmdLine.hasOption(AS_OPTION)) {
173      try {
174        String asFormatText = cmdLine.getOptionValue(AS_OPTION);
175        asFormat = Format.valueOf(asFormatText.toUpperCase(Locale.ROOT));
176      } catch (IllegalArgumentException ex) {
177        return ExitCode.INVALID_ARGUMENTS
178            .exitMessage("Invalid '--as' argument. The format must be one of: " + Arrays.stream(Format.values())
179                .map(Enum::name)
180                .collect(CustomCollectors.joiningWithOxfordComma("or")));
181      }
182    } else {
183      // attempt to determine the format
184      try {
185        asFormat = loader.detectFormat(ObjectUtils.notNull(source));
186      } catch (FileNotFoundException ex) {
187        // this case was already checked for
188        return ExitCode.IO_ERROR.exitMessage("The provided source file '" + source + "' does not exist.");
189      } catch (IOException ex) {
190        return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex);
191      } catch (IllegalArgumentException ex) {
192        return ExitCode.INVALID_ARGUMENTS.exitMessage(
193            "Source file has unrecognizable format. Use '--as' to specify the format. The format must be one of: "
194                + Arrays.stream(Format.values())
195                    .map(Enum::name)
196                    .collect(CustomCollectors.joiningWithOxfordComma("or")));
197      }
198    }
199
200    source = source.toAbsolutePath();
201    assert source != null;
202
203    Format toFormat;
204    if (cmdLine.hasOption(TO_OPTION)) {
205      String toFormatText = cmdLine.getOptionValue(TO_OPTION);
206      toFormat = Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
207    } else {
208      toFormat = asFormat;
209    }
210
211    Path destination = null;
212    if (extraArgs.size() == 2) {
213      destination = Paths.get(extraArgs.get(1)).toAbsolutePath();
214    }
215
216    if (destination != null) {
217      if (Files.exists(destination)) {
218        if (!cmdLine.hasOption(OVERWRITE_OPTION)) {
219          return ExitCode.INVALID_ARGUMENTS.exitMessage("The provided destination '" + destination
220              + "' already exists and the --overwrite option was not provided.");
221        }
222        if (!Files.isWritable(destination)) {
223          return ExitCode.IO_ERROR.exitMessage("The provided destination '" + destination + "' is not writable.");
224        }
225      } else {
226        Path parent = destination.getParent();
227        if (parent != null) {
228          try {
229            Files.createDirectories(parent);
230          } catch (IOException ex) {
231            return ExitCode.INVALID_TARGET.exit().withThrowable(ex);
232          }
233        }
234      }
235    }
236
237    IDocumentNodeItem document;
238    try {
239      document = loader.loadAsNodeItem(asFormat, source);
240    } catch (IOException ex) {
241      return ExitCode.IO_ERROR.exit().withThrowable(ex);
242    }
243    Object object = document.getValue();
244    if (object == null) {
245      return ExitCode.INVALID_ARGUMENTS.exitMessage("The target profile contained no data");
246    }
247
248    if (object instanceof Catalog) {
249      // this is a catalog
250      return ExitCode.INVALID_ARGUMENTS.exitMessage("The target is already a catalog");
251    }
252
253    if (!(object instanceof Profile)) {
254      // this is something else
255      return ExitCode.INVALID_ARGUMENTS.exitMessage("The target is not a profile");
256    }
257
258    // this is a profile
259    DynamicContext dynamicContext = new DynamicContext(document.getStaticContext());
260    dynamicContext.setDocumentLoader(loader);
261    ProfileResolver resolver = new ProfileResolver();
262    resolver.setDynamicContext(dynamicContext);
263
264    IDocumentNodeItem resolvedProfile;
265    try {
266      resolvedProfile = resolver.resolve(document);
267    } catch (IOException | ProfileResolutionException ex) {
268      return ExitCode.PROCESSING_ERROR
269          .exitMessage(
270              String.format("Cmd: Unable to resolve profile '%s'. %s", document.getDocumentUri(), ex.getMessage()))
271          .withThrowable(ex);
272    }
273
274    // DefaultConstraintValidator validator = new
275    // DefaultConstraintValidator(dynamicContext);
276    // ((IBoundXdmNodeItem)resolvedProfile).validate(validator);
277    // validator.finalizeValidation();
278
279    ISerializer<Catalog> serializer
280        = OscalBindingContext.instance().newSerializer(toFormat, Catalog.class);
281    try {
282      if (destination == null) {
283        @SuppressWarnings({ "resource", "PMD.CloseResource" }) PrintStream stdOut = ObjectUtils.notNull(System.out);
284        serializer.serialize((Catalog) INodeItem.toValue(resolvedProfile), stdOut);
285      } else {
286        serializer.serialize((Catalog) INodeItem.toValue(resolvedProfile), destination);
287      }
288    } catch (IOException ex) {
289      return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex);
290    }
291    return ExitCode.OK.exit();
292  }
293}