1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.metaschema.cli.commands;
7   
8   import gov.nist.secauto.metaschema.cli.processor.CLIProcessor;
9   import gov.nist.secauto.metaschema.cli.processor.CLIProcessor.CallingContext;
10  import gov.nist.secauto.metaschema.cli.processor.ExitCode;
11  import gov.nist.secauto.metaschema.cli.processor.ExitStatus;
12  import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException;
13  import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
14  import gov.nist.secauto.metaschema.cli.processor.command.AbstractCommandExecutor;
15  import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand;
16  import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument;
17  import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
18  import gov.nist.secauto.metaschema.cli.util.LoggingValidationHandler;
19  import gov.nist.secauto.metaschema.core.configuration.DefaultConfiguration;
20  import gov.nist.secauto.metaschema.core.configuration.IMutableConfiguration;
21  import gov.nist.secauto.metaschema.core.metapath.MetapathException;
22  import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
23  import gov.nist.secauto.metaschema.core.model.MetaschemaException;
24  import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
25  import gov.nist.secauto.metaschema.core.model.constraint.ValidationFeature;
26  import gov.nist.secauto.metaschema.core.model.validation.AggregateValidationResult;
27  import gov.nist.secauto.metaschema.core.model.validation.IValidationResult;
28  import gov.nist.secauto.metaschema.core.util.CollectionUtil;
29  import gov.nist.secauto.metaschema.core.util.CustomCollectors;
30  import gov.nist.secauto.metaschema.core.util.IVersionInfo;
31  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
32  import gov.nist.secauto.metaschema.core.util.UriUtils;
33  import gov.nist.secauto.metaschema.databind.IBindingContext;
34  import gov.nist.secauto.metaschema.databind.IBindingContext.ISchemaValidationProvider;
35  import gov.nist.secauto.metaschema.databind.io.Format;
36  import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
37  import gov.nist.secauto.metaschema.databind.model.metaschema.BindingConstraintLoader;
38  import gov.nist.secauto.metaschema.modules.sarif.SarifValidationHandler;
39  
40  import org.apache.commons.cli.CommandLine;
41  import org.apache.commons.cli.Option;
42  import org.apache.logging.log4j.LogManager;
43  import org.apache.logging.log4j.Logger;
44  
45  import java.io.FileNotFoundException;
46  import java.io.IOException;
47  import java.net.URI;
48  import java.net.URISyntaxException;
49  import java.net.UnknownHostException;
50  import java.nio.file.Path;
51  import java.nio.file.Paths;
52  import java.util.Arrays;
53  import java.util.Collection;
54  import java.util.LinkedHashSet;
55  import java.util.List;
56  import java.util.Locale;
57  import java.util.Set;
58  
59  import edu.umd.cs.findbugs.annotations.NonNull;
60  
61  public abstract class AbstractValidateContentCommand
62      extends AbstractTerminalCommand {
63    private static final Logger LOGGER = LogManager.getLogger(AbstractValidateContentCommand.class);
64    @NonNull
65    private static final String COMMAND = "validate";
66    @NonNull
67    private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
68        new DefaultExtraArgument("file-or-URI-to-validate", true)));
69  
70    @NonNull
71    private static final Option AS_OPTION = ObjectUtils.notNull(
72        Option.builder()
73            .longOpt("as")
74            .hasArg()
75            .argName("FORMAT")
76            .desc("source format: xml, json, or yaml")
77            .numberOfArgs(1)
78            .build());
79    @NonNull
80    private static final Option CONSTRAINTS_OPTION = ObjectUtils.notNull(
81        Option.builder("c")
82            .hasArgs()
83            .argName("URL")
84            .desc("additional constraint definitions")
85            .build());
86    @NonNull
87    private static final Option SARIF_OUTPUT_FILE_OPTION = ObjectUtils.notNull(
88        Option.builder("o")
89            .hasArg()
90            .argName("FILE")
91            .desc("write SARIF results to the provided FILE")
92            .numberOfArgs(1)
93            .build());
94    @NonNull
95    private static final Option SARIF_INCLUDE_PASS_OPTION = ObjectUtils.notNull(
96        Option.builder()
97            .longOpt("sarif-include-pass")
98            .desc("include pass results in SARIF")
99            .build());
100   @NonNull
101   private static final Option NO_SCHEMA_VALIDATION_OPTION = ObjectUtils.notNull(
102       Option.builder()
103           .longOpt("disable-schema-validation")
104           .desc("do not perform schema validation")
105           .build());
106   @NonNull
107   private static final Option NO_CONSTRAINT_VALIDATION_OPTION = ObjectUtils.notNull(
108       Option.builder()
109           .longOpt("disable-constraint-validation")
110           .desc("do not perform constraint validation")
111           .build());
112 
113   @Override
114   public String getName() {
115     return COMMAND;
116   }
117 
118   @SuppressWarnings("null")
119   @Override
120   public Collection<? extends Option> gatherOptions() {
121     return List.of(
122         AS_OPTION,
123         CONSTRAINTS_OPTION,
124         SARIF_OUTPUT_FILE_OPTION,
125         SARIF_INCLUDE_PASS_OPTION,
126         NO_SCHEMA_VALIDATION_OPTION,
127         NO_CONSTRAINT_VALIDATION_OPTION);
128   }
129 
130   @Override
131   public List<ExtraArgument> getExtraArguments() {
132     return EXTRA_ARGUMENTS;
133   }
134 
135   @SuppressWarnings("PMD.PreserveStackTrace") // intended
136   @Override
137   public void validateOptions(CallingContext callingContext, CommandLine cmdLine) throws InvalidArgumentException {
138     List<String> extraArgs = cmdLine.getArgList();
139     if (extraArgs.size() != 1) {
140       throw new InvalidArgumentException("The source to validate must be provided.");
141     }
142 
143     if (cmdLine.hasOption(AS_OPTION)) {
144       try {
145         String toFormatText = cmdLine.getOptionValue(AS_OPTION);
146         Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
147       } catch (IllegalArgumentException ex) {
148         InvalidArgumentException newEx = new InvalidArgumentException(
149             String.format("Invalid '%s' argument. The format must be one of: %s.",
150                 OptionUtils.toArgument(AS_OPTION),
151                 Arrays.asList(Format.values()).stream()
152                     .map(format -> format.name())
153                     .collect(CustomCollectors.joiningWithOxfordComma("and"))));
154         newEx.addSuppressed(ex);
155         throw newEx;
156       }
157     }
158   }
159 
160   protected abstract class AbstractValidationCommandExecutor
161       extends AbstractCommandExecutor
162       implements ISchemaValidationProvider {
163 
164     /**
165      * Construct a new command executor.
166      *
167      * @param callingContext
168      *          the context of the command execution
169      * @param commandLine
170      *          the parsed command line details
171      */
172     public AbstractValidationCommandExecutor(
173         @NonNull CallingContext callingContext,
174         @NonNull CommandLine commandLine) {
175       super(callingContext, commandLine);
176     }
177 
178     /**
179      * Get the binding context to use for data processing.
180      *
181      * @param constraintSets
182      *          the constraints to configure in the resulting binding context
183      * @return the context
184      * @throws MetaschemaException
185      *           if a Metaschema error occurred
186      * @throws IOException
187      *           if an error occurred while reading data
188      */
189     @NonNull
190     protected abstract IBindingContext getBindingContext(@NonNull Set<IConstraintSet> constraintSets)
191         throws MetaschemaException, IOException;
192 
193     @SuppressWarnings("PMD.OnlyOneReturn") // readability
194     @Override
195     public ExitStatus execute() {
196       URI cwd = ObjectUtils.notNull(Paths.get("").toAbsolutePath().toUri());
197       CommandLine cmdLine = getCommandLine();
198 
199       Set<IConstraintSet> constraintSets;
200       if (cmdLine.hasOption(CONSTRAINTS_OPTION)) {
201         IConstraintLoader constraintLoader = new BindingConstraintLoader(IBindingContext.instance());
202         constraintSets = new LinkedHashSet<>();
203         String[] args = cmdLine.getOptionValues(CONSTRAINTS_OPTION);
204         for (String arg : args) {
205           assert arg != null;
206           try {
207             URI constraintUri = ObjectUtils.requireNonNull(UriUtils.toUri(arg, cwd));
208             constraintSets.addAll(constraintLoader.load(constraintUri));
209           } catch (IOException | MetaschemaException | MetapathException | URISyntaxException ex) {
210             return ExitCode.IO_ERROR.exitMessage("Unable to load constraint set '" + arg + "'.").withThrowable(ex);
211           }
212         }
213       } else {
214         constraintSets = CollectionUtil.emptySet();
215       }
216 
217       IBindingContext bindingContext;
218       try {
219         bindingContext = getBindingContext(constraintSets);
220       } catch (IOException | MetaschemaException ex) {
221         return ExitCode.PROCESSING_ERROR
222             .exitMessage("Unable to get binding context. " + ex.getMessage())
223             .withThrowable(ex);
224       }
225 
226       IBoundLoader loader = bindingContext.newBoundLoader();
227 
228       List<String> extraArgs = cmdLine.getArgList();
229 
230       String sourceName = ObjectUtils.requireNonNull(extraArgs.get(0));
231       URI source;
232 
233       try {
234         source = UriUtils.toUri(sourceName, cwd);
235       } catch (URISyntaxException ex) {
236         return ExitCode.IO_ERROR.exitMessage("Cannot load source '%s' as it is not a valid file or URI.")
237             .withThrowable(ex);
238       }
239 
240       Format asFormat;
241       if (cmdLine.hasOption(AS_OPTION)) {
242         try {
243           String toFormatText = cmdLine.getOptionValue(AS_OPTION);
244           asFormat = Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
245         } catch (IllegalArgumentException ex) {
246           return ExitCode.IO_ERROR
247               .exitMessage("Invalid '--as' argument. The format must be one of: "
248                   + Arrays.stream(Format.values())
249                       .map(format -> format.name())
250                       .collect(CustomCollectors.joiningWithOxfordComma("or")))
251               .withThrowable(ex);
252         }
253       } else {
254         // attempt to determine the format
255         try {
256           asFormat = loader.detectFormat(source);
257         } catch (FileNotFoundException ex) {
258           // this case was already checked for
259           return ExitCode.IO_ERROR.exitMessage("The provided source file '" + source + "' does not exist.");
260         } catch (IOException ex) {
261           return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex);
262         } catch (IllegalArgumentException ex) {
263           return ExitCode.IO_ERROR.exitMessage(
264               "Source file has unrecognizable format. Use '--as' to specify the format. The format must be one of: "
265                   + Arrays.stream(Format.values())
266                       .map(format -> format.name())
267                       .collect(CustomCollectors.joiningWithOxfordComma("or")));
268         }
269       }
270 
271       if (LOGGER.isInfoEnabled()) {
272         LOGGER.info("Validating '{}' as {}.", source, asFormat.name());
273       }
274 
275       IMutableConfiguration<ValidationFeature<?>> configuration = new DefaultConfiguration<>();
276       if (cmdLine.hasOption(SARIF_OUTPUT_FILE_OPTION) && cmdLine.hasOption(SARIF_INCLUDE_PASS_OPTION)) {
277         configuration.enableFeature(ValidationFeature.VALIDATE_GENERATE_PASS_FINDINGS);
278       }
279 
280       IValidationResult validationResult = null;
281       try {
282         if (!cmdLine.hasOption(NO_SCHEMA_VALIDATION_OPTION)) {
283           // perform schema validation
284           validationResult = this.validateWithSchema(source, asFormat);
285         }
286 
287         if (!cmdLine.hasOption(NO_CONSTRAINT_VALIDATION_OPTION)
288             && (validationResult == null || validationResult.isPassing())) {
289           // perform constraint validation
290           IValidationResult constraintValidationResult = bindingContext.validateWithConstraints(source, configuration);
291           validationResult = validationResult == null
292               ? constraintValidationResult
293               : AggregateValidationResult.aggregate(validationResult, constraintValidationResult);
294         }
295       } catch (FileNotFoundException ex) {
296         return ExitCode.IO_ERROR.exitMessage(String.format("Resource not found at '%s'", source)).withThrowable(ex);
297       } catch (UnknownHostException ex) {
298         return ExitCode.IO_ERROR.exitMessage(String.format("Unknown host for '%s'.", source)).withThrowable(ex);
299       } catch (IOException ex) {
300         return ExitCode.IO_ERROR.exit().withThrowable(ex);
301       } catch (MetapathException ex) {
302         return ExitCode.PROCESSING_ERROR.exit().withThrowable(ex);
303       }
304 
305       if (cmdLine.hasOption(SARIF_OUTPUT_FILE_OPTION) && LOGGER.isInfoEnabled()) {
306         Path sarifFile = ObjectUtils.notNull(Paths.get(cmdLine.getOptionValue(SARIF_OUTPUT_FILE_OPTION)));
307 
308         IVersionInfo version
309             = getCallingContext().getCLIProcessor().getVersionInfos().get(CLIProcessor.COMMAND_VERSION);
310 
311         try {
312           SarifValidationHandler sarifHandler = new SarifValidationHandler(source, version);
313           if (validationResult != null) {
314             sarifHandler.addFindings(validationResult.getFindings());
315           }
316           sarifHandler.write(sarifFile);
317         } catch (IOException ex) {
318           return ExitCode.IO_ERROR.exit().withThrowable(ex);
319         }
320       } else if (validationResult != null && !validationResult.getFindings().isEmpty()) {
321         LOGGER.info("Validation identified the following issues:", source);
322         LoggingValidationHandler.instance().handleValidationResults(validationResult);
323       }
324 
325       if (validationResult == null || validationResult.isPassing()) {
326         if (LOGGER.isInfoEnabled()) {
327           LOGGER.info("The file '{}' is valid.", source);
328         }
329       } else if (LOGGER.isErrorEnabled()) {
330         LOGGER.error("The file '{}' is invalid.", source);
331       }
332 
333       return (validationResult == null || validationResult.isPassing() ? ExitCode.OK : ExitCode.FAIL).exit();
334     }
335   }
336 }