1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.oscal.tools.cli.core.commands;
7   
8   import gov.nist.secauto.metaschema.cli.processor.CLIProcessor.CallingContext;
9   import gov.nist.secauto.metaschema.cli.processor.ExitCode;
10  import gov.nist.secauto.metaschema.cli.processor.ExitStatus;
11  import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException;
12  import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
13  import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand;
14  import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument;
15  import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
16  import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
17  import gov.nist.secauto.metaschema.core.metapath.DynamicContext;
18  import gov.nist.secauto.metaschema.core.metapath.item.node.IDocumentNodeItem;
19  import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem;
20  import gov.nist.secauto.metaschema.core.util.CustomCollectors;
21  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
22  import gov.nist.secauto.metaschema.databind.io.DeserializationFeature;
23  import gov.nist.secauto.metaschema.databind.io.Format;
24  import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
25  import gov.nist.secauto.metaschema.databind.io.ISerializer;
26  import gov.nist.secauto.oscal.lib.OscalBindingContext;
27  import gov.nist.secauto.oscal.lib.model.Catalog;
28  import gov.nist.secauto.oscal.lib.model.Profile;
29  import gov.nist.secauto.oscal.lib.profile.resolver.ProfileResolutionException;
30  import gov.nist.secauto.oscal.lib.profile.resolver.ProfileResolver;
31  
32  import org.apache.commons.cli.CommandLine;
33  import org.apache.commons.cli.Option;
34  
35  import java.io.File;
36  import java.io.FileNotFoundException;
37  import java.io.IOException;
38  import java.io.PrintStream;
39  import java.nio.file.Files;
40  import java.nio.file.Path;
41  import java.nio.file.Paths;
42  import java.util.Arrays;
43  import java.util.Collection;
44  import java.util.List;
45  import java.util.Locale;
46  
47  import edu.umd.cs.findbugs.annotations.NonNull;
48  
49  public abstract class AbstractResolveCommand
50      extends AbstractTerminalCommand {
51    @NonNull
52    private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
53        new DefaultExtraArgument("file to resolve", true),
54        new DefaultExtraArgument("destination file", false)));
55    @NonNull
56    private static final Option AS_OPTION = ObjectUtils.notNull(
57        Option.builder()
58            .longOpt("as")
59            .hasArg()
60            .argName("FORMAT")
61            .desc("source format: xml, json, or yaml")
62            .build());
63    @NonNull
64    private static final Option TO_OPTION = ObjectUtils.notNull(
65        Option.builder()
66            .longOpt("to")
67            .required()
68            .hasArg().argName("FORMAT")
69            .desc("convert to format: xml, json, or yaml")
70            .build());
71    @NonNull
72    private static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
73        Option.builder()
74            .longOpt("overwrite")
75            .desc("overwrite the destination if it exists")
76            .build());
77    @NonNull
78    private static final List<Option> OPTIONS = ObjectUtils.notNull(
79        List.of(
80            AS_OPTION,
81            TO_OPTION,
82            OVERWRITE_OPTION));
83  
84    @Override
85    public String getDescription() {
86      return "Resolve the specified OSCAL Profile";
87    }
88  
89    @Override
90    public Collection<? extends Option> gatherOptions() {
91      return OPTIONS;
92    }
93  
94    @Override
95    public List<ExtraArgument> getExtraArguments() {
96      return EXTRA_ARGUMENTS;
97    }
98  
99    @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 }