1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.oscal.tools.cli.core.commands;
7   
8   import org.apache.commons.cli.CommandLine;
9   import org.apache.commons.cli.Option;
10  
11  import java.io.IOException;
12  import java.io.PrintStream;
13  import java.net.URI;
14  import java.net.URISyntaxException;
15  import java.nio.file.Path;
16  import java.util.Collection;
17  import java.util.List;
18  
19  import dev.metaschema.cli.commands.MetaschemaCommands;
20  import dev.metaschema.cli.processor.CallingContext;
21  import dev.metaschema.cli.processor.ExitCode;
22  import dev.metaschema.cli.processor.command.AbstractTerminalCommand;
23  import dev.metaschema.cli.processor.command.CommandExecutionException;
24  import dev.metaschema.cli.processor.command.ExtraArgument;
25  import dev.metaschema.cli.processor.command.ICommandExecutor;
26  import dev.metaschema.core.metapath.DynamicContext;
27  import dev.metaschema.core.metapath.item.node.IDocumentNodeItem;
28  import dev.metaschema.core.metapath.item.node.INodeItem;
29  import dev.metaschema.core.util.ObjectUtils;
30  import dev.metaschema.core.util.UriUtils;
31  import dev.metaschema.databind.IBindingContext;
32  import dev.metaschema.databind.io.DeserializationFeature;
33  import dev.metaschema.databind.io.Format;
34  import dev.metaschema.databind.io.IBoundLoader;
35  import dev.metaschema.databind.io.ISerializer;
36  import dev.metaschema.oscal.lib.OscalBindingContext;
37  import dev.metaschema.oscal.lib.model.Catalog;
38  import dev.metaschema.oscal.lib.model.Profile;
39  import dev.metaschema.oscal.lib.profile.resolver.ProfileResolutionException;
40  import dev.metaschema.oscal.lib.profile.resolver.ProfileResolver;
41  import dev.metaschema.oscal.tools.cli.core.utils.PrettyPrinter;
42  import edu.umd.cs.findbugs.annotations.NonNull;
43  
44  /**
45   * A command implementation supporting the resolution of an OSCAL profile.
46   */
47  public abstract class AbstractResolveCommand
48      extends AbstractTerminalCommand {
49    @NonNull
50    private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
51        ExtraArgument.newInstance("URI to resolve", true),
52        ExtraArgument.newInstance("destination file", false)));
53    private static final Option RELATIVE_TO = Option.builder()
54        .longOpt("relative-to")
55        .desc("Generate URI references relative to this resource")
56        .hasArg()
57        .build();
58    private static final Option PRETTY_PRINT_OPTION = Option.builder()
59        .longOpt("pretty-print")
60        .desc("Enable pretty-printing of the output for better readability")
61        .build();
62  
63    @NonNull
64    private static final List<Option> OPTIONS = ObjectUtils.notNull(
65        List.of(
66            MetaschemaCommands.AS_FORMAT_OPTION,
67            MetaschemaCommands.TO_OPTION,
68            MetaschemaCommands.OVERWRITE_OPTION,
69            RELATIVE_TO,
70            PRETTY_PRINT_OPTION));
71  
72    @Override
73    public String getDescription() {
74      return "Resolve the specified OSCAL Profile";
75    }
76  
77    @Override
78    public Collection<? extends Option> gatherOptions() {
79      return OPTIONS;
80    }
81  
82    @Override
83    public List<ExtraArgument> getExtraArguments() {
84      return EXTRA_ARGUMENTS;
85    }
86  
87    @SuppressWarnings({
88        "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity", // reasonable
89        "PMD.PreserveStackTrace" // intended
90    })
91  
92    @Override
93    public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) {
94      return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand);
95    }
96  
97    /**
98     * Process the command line arguments and execute the profile resolution
99     * 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 }