001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.cli.commands;
007
008import gov.nist.secauto.metaschema.cli.processor.CLIProcessor;
009import gov.nist.secauto.metaschema.cli.processor.CLIProcessor.CallingContext;
010import gov.nist.secauto.metaschema.cli.processor.ExitCode;
011import gov.nist.secauto.metaschema.cli.processor.ExitStatus;
012import gov.nist.secauto.metaschema.cli.processor.InvalidArgumentException;
013import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
014import gov.nist.secauto.metaschema.cli.processor.command.AbstractCommandExecutor;
015import gov.nist.secauto.metaschema.cli.processor.command.AbstractTerminalCommand;
016import gov.nist.secauto.metaschema.cli.processor.command.DefaultExtraArgument;
017import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
018import gov.nist.secauto.metaschema.cli.util.LoggingValidationHandler;
019import gov.nist.secauto.metaschema.core.configuration.DefaultConfiguration;
020import gov.nist.secauto.metaschema.core.configuration.IMutableConfiguration;
021import gov.nist.secauto.metaschema.core.metapath.MetapathException;
022import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
023import gov.nist.secauto.metaschema.core.model.MetaschemaException;
024import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
025import gov.nist.secauto.metaschema.core.model.constraint.ValidationFeature;
026import gov.nist.secauto.metaschema.core.model.validation.AggregateValidationResult;
027import gov.nist.secauto.metaschema.core.model.validation.IValidationResult;
028import gov.nist.secauto.metaschema.core.util.CollectionUtil;
029import gov.nist.secauto.metaschema.core.util.CustomCollectors;
030import gov.nist.secauto.metaschema.core.util.IVersionInfo;
031import gov.nist.secauto.metaschema.core.util.ObjectUtils;
032import gov.nist.secauto.metaschema.core.util.UriUtils;
033import gov.nist.secauto.metaschema.databind.IBindingContext;
034import gov.nist.secauto.metaschema.databind.IBindingContext.ISchemaValidationProvider;
035import gov.nist.secauto.metaschema.databind.io.Format;
036import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
037import gov.nist.secauto.metaschema.databind.model.metaschema.BindingConstraintLoader;
038import gov.nist.secauto.metaschema.modules.sarif.SarifValidationHandler;
039
040import org.apache.commons.cli.CommandLine;
041import org.apache.commons.cli.Option;
042import org.apache.logging.log4j.LogManager;
043import org.apache.logging.log4j.Logger;
044
045import java.io.FileNotFoundException;
046import java.io.IOException;
047import java.net.URI;
048import java.net.URISyntaxException;
049import java.net.UnknownHostException;
050import java.nio.file.Path;
051import java.nio.file.Paths;
052import java.util.Arrays;
053import java.util.Collection;
054import java.util.LinkedHashSet;
055import java.util.List;
056import java.util.Locale;
057import java.util.Set;
058
059import edu.umd.cs.findbugs.annotations.NonNull;
060
061public abstract class AbstractValidateContentCommand
062    extends AbstractTerminalCommand {
063  private static final Logger LOGGER = LogManager.getLogger(AbstractValidateContentCommand.class);
064  @NonNull
065  private static final String COMMAND = "validate";
066  @NonNull
067  private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
068      new DefaultExtraArgument("file-or-URI-to-validate", true)));
069
070  @NonNull
071  private static final Option AS_OPTION = ObjectUtils.notNull(
072      Option.builder()
073          .longOpt("as")
074          .hasArg()
075          .argName("FORMAT")
076          .desc("source format: xml, json, or yaml")
077          .numberOfArgs(1)
078          .build());
079  @NonNull
080  private static final Option CONSTRAINTS_OPTION = ObjectUtils.notNull(
081      Option.builder("c")
082          .hasArgs()
083          .argName("URL")
084          .desc("additional constraint definitions")
085          .build());
086  @NonNull
087  private static final Option SARIF_OUTPUT_FILE_OPTION = ObjectUtils.notNull(
088      Option.builder("o")
089          .hasArg()
090          .argName("FILE")
091          .desc("write SARIF results to the provided FILE")
092          .numberOfArgs(1)
093          .build());
094  @NonNull
095  private static final Option SARIF_INCLUDE_PASS_OPTION = ObjectUtils.notNull(
096      Option.builder()
097          .longOpt("sarif-include-pass")
098          .desc("include pass results in SARIF")
099          .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}