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.commands.metapath.MetapathCommand;
9   import gov.nist.secauto.metaschema.cli.processor.ExitCode;
10  import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
11  import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
12  import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
13  import gov.nist.secauto.metaschema.core.metapath.MetapathException;
14  import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
15  import gov.nist.secauto.metaschema.core.model.IModule;
16  import gov.nist.secauto.metaschema.core.model.MetaschemaException;
17  import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
18  import gov.nist.secauto.metaschema.core.util.CollectionUtil;
19  import gov.nist.secauto.metaschema.core.util.CustomCollectors;
20  import gov.nist.secauto.metaschema.core.util.DeleteOnShutdown;
21  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
22  import gov.nist.secauto.metaschema.core.util.UriUtils;
23  import gov.nist.secauto.metaschema.databind.IBindingContext;
24  import gov.nist.secauto.metaschema.databind.io.Format;
25  import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
26  import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingModuleLoader;
27  import gov.nist.secauto.metaschema.schemagen.ISchemaGenerator.SchemaFormat;
28  
29  import org.apache.commons.cli.CommandLine;
30  import org.apache.commons.cli.Option;
31  
32  import java.io.FileNotFoundException;
33  import java.io.IOException;
34  import java.net.URI;
35  import java.net.URISyntaxException;
36  import java.nio.file.Files;
37  import java.nio.file.Path;
38  import java.nio.file.Paths;
39  import java.util.Arrays;
40  import java.util.LinkedHashSet;
41  import java.util.List;
42  import java.util.Locale;
43  import java.util.Set;
44  
45  import edu.umd.cs.findbugs.annotations.NonNull;
46  
47  /**
48   * This class provides a variety of utility methods for processing
49   * Metaschema-related commands.
50   * <p>
51   * These methods handle the errors produced using the
52   * {@link CommandExecutionException}, which will return an exceptional result to
53   * the command line interface (CLI) processor. This approach keeps the command
54   * implementations fairly clean and simple.
55   */
56  @SuppressWarnings("PMD.GodClass")
57  public final class MetaschemaCommands {
58    /**
59     * A list of the Metaschema-related command pathways, for reuse in this and
60     * other CLI applications.
61     */
62    @NonNull
63    public static final List<ICommand> COMMANDS = ObjectUtils.notNull(List.of(
64        new ValidateModuleCommand(),
65        new GenerateSchemaCommand(),
66        new GenerateDiagramCommand(),
67        new ValidateContentUsingModuleCommand(),
68        new ConvertContentUsingModuleCommand(),
69        new MetapathCommand()));
70  
71    /**
72     * Used by commands to declare a required Metaschema module for processing.
73     *
74     * @since 2.0.0
75     */
76    @NonNull
77    public static final Option METASCHEMA_REQUIRED_OPTION = ObjectUtils.notNull(
78        Option.builder("m")
79            .hasArg()
80            .argName("FILE_OR_URL")
81            .required()
82            .desc("metaschema resource")
83            .numberOfArgs(1)
84            .build());
85    /**
86     * Used by commands to declare an optional Metaschema module for processing.
87     *
88     * @since 2.0.0
89     */
90    @NonNull
91    public static final Option METASCHEMA_OPTIONAL_OPTION = ObjectUtils.notNull(
92        Option.builder("m")
93            .hasArg()
94            .argName("FILE_OR_URL")
95            .desc("metaschema resource")
96            .numberOfArgs(1)
97            .build());
98    /**
99     * Used by commands to protect existing files from being overwritten, unless
100    * this option is provided.
101    */
102   @NonNull
103   public static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
104       Option.builder()
105           .longOpt("overwrite")
106           .desc("overwrite the destination if it exists")
107           .build());
108   /**
109    * Used by commands to identify the target format for a content conversion
110    * operation.
111    *
112    * @since 2.0.0
113    */
114   @NonNull
115   public static final Option TO_OPTION = ObjectUtils.notNull(
116       Option.builder()
117           .longOpt("to")
118           .required()
119           .hasArg().argName("FORMAT")
120           .desc("convert to format: " + Arrays.stream(Format.values())
121               .map(Enum::name)
122               .collect(CustomCollectors.joiningWithOxfordComma("or")))
123           .numberOfArgs(1)
124           .build());
125   /**
126    * Used by commands to identify the source format for a content-related
127    * operation.
128    *
129    * @since 2.0.0
130    */
131   @NonNull
132   public static final Option AS_FORMAT_OPTION = ObjectUtils.notNull(
133       Option.builder()
134           .longOpt("as")
135           .hasArg()
136           .argName("FORMAT")
137           .desc("source format: " + Arrays.stream(Format.values())
138               .map(Enum::name)
139               .collect(CustomCollectors.joiningWithOxfordComma("or")))
140           .numberOfArgs(1)
141           .build());
142   /**
143    * Used by commands that produce schemas to identify the schema format to
144    * produce.
145    *
146    * @since 2.0.0
147    */
148   @NonNull
149   public static final Option AS_SCHEMA_FORMAT_OPTION = ObjectUtils.notNull(
150       Option.builder()
151           .longOpt("as")
152           .required()
153           .hasArg()
154           .argName("FORMAT")
155           .desc("schema format: " + Arrays.stream(SchemaFormat.values())
156               .map(Enum::name)
157               .collect(CustomCollectors.joiningWithOxfordComma("or")))
158           .numberOfArgs(1)
159           .build());
160 
161   /**
162    * Get the provided source path or URI string as an absolute {@link URI} for the
163    * resource.
164    *
165    * @param pathOrUri
166    *          the resource
167    * @param currentWorkingDirectory
168    *          the current working directory the URI will be resolved against to
169    *          ensure it is absolute
170    * @return the absolute URI for the resource
171    * @throws CommandExecutionException
172    *           if the resulting URI is not a well-formed URI
173    * @since 2.0.0
174    */
175   @NonNull
176   public static URI handleSource(
177       @NonNull String pathOrUri,
178       @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
179     try {
180       return getResourceUri(pathOrUri, currentWorkingDirectory);
181     } catch (URISyntaxException ex) {
182       throw new CommandExecutionException(
183           ExitCode.INVALID_ARGUMENTS,
184           String.format(
185               "Cannot load source '%s' as it is not a valid file or URI.",
186               pathOrUri),
187           ex);
188     }
189   }
190 
191   /**
192    * Get the provided destination path as an absolute {@link Path} for the
193    * resource.
194    * <p>
195    * This method checks if the path exists and if so, if the overwrite option is
196    * set. The method also ensures that the parent directory is created, if it
197    * doesn't already exist.
198    *
199    * @param path
200    *          the resource
201    * @param commandLine
202    *          the provided command line argument information
203    * @return the absolute URI for the resource
204    * @throws CommandExecutionException
205    *           if the path exists and cannot be overwritten or is not writable
206    * @since 2.0.0
207    */
208   public static Path handleDestination(
209       @NonNull String path,
210       @NonNull CommandLine commandLine) throws CommandExecutionException {
211     Path retval = Paths.get(path).toAbsolutePath();
212 
213     if (Files.exists(retval)) {
214       if (!commandLine.hasOption(OVERWRITE_OPTION)) {
215         throw new CommandExecutionException(
216             ExitCode.INVALID_ARGUMENTS,
217             String.format("The provided destination '%s' already exists and the '%s' option was not provided.",
218                 retval,
219                 OptionUtils.toArgument(OVERWRITE_OPTION)));
220       }
221       if (!Files.isWritable(retval)) {
222         throw new CommandExecutionException(
223             ExitCode.IO_ERROR,
224             String.format(
225                 "The provided destination '%s' is not writable.", retval));
226       }
227     } else {
228       Path parent = retval.getParent();
229       if (parent != null) {
230         try {
231           Files.createDirectories(parent);
232         } catch (IOException ex) {
233           throw new CommandExecutionException(
234               ExitCode.INVALID_TARGET,
235               ex);
236         }
237       }
238     }
239     return retval;
240   }
241 
242   /**
243    * Parse the command line options to get the selected format.
244    *
245    * @param commandLine
246    *          the provided command line argument information
247    * @param option
248    *          the option specifying the format, which must be present on the
249    *          command line
250    * @return the format
251    * @throws CommandExecutionException
252    *           if the format option was not provided or was an invalid choice
253    * @since 2.0.0
254    */
255   @SuppressWarnings("PMD.PreserveStackTrace")
256   @NonNull
257   public static Format getFormat(
258       @NonNull CommandLine commandLine,
259       @NonNull Option option) throws CommandExecutionException {
260     // use the option
261     String toFormatText = commandLine.getOptionValue(option);
262     if (toFormatText == null) {
263       throw new CommandExecutionException(
264           ExitCode.INVALID_ARGUMENTS,
265           String.format("The '%s' argument was not provided.",
266               option.hasLongOpt()
267                   ? "--" + option.getLongOpt()
268                   : "-" + option.getOpt()));
269     }
270     try {
271       return Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
272     } catch (IllegalArgumentException ex) {
273       throw new CommandExecutionException(
274           ExitCode.INVALID_ARGUMENTS,
275           String.format("Invalid '%s' argument. The format must be one of: %s.",
276               option.hasLongOpt()
277                   ? "--" + option.getLongOpt()
278                   : "-" + option.getOpt(),
279               Arrays.stream(Format.values())
280                   .map(Enum::name)
281                   .collect(CustomCollectors.joiningWithOxfordComma("or"))));
282     }
283   }
284 
285   /**
286    * Parse the command line options to get the selected schema format.
287    *
288    * @param commandLine
289    *          the provided command line argument information
290    * @param option
291    *          the option specifying the format, which must be present on the
292    *          command line
293    * @return the format
294    * @throws CommandExecutionException
295    *           if the format option was not provided or was an invalid choice
296    * @since 2.0.0
297    */
298   @SuppressWarnings("PMD.PreserveStackTrace")
299   @NonNull
300   public static SchemaFormat getSchemaFormat(
301       @NonNull CommandLine commandLine,
302       @NonNull Option option) throws CommandExecutionException {
303     // use the option
304     String toFormatText = commandLine.getOptionValue(option);
305     if (toFormatText == null) {
306       throw new CommandExecutionException(
307           ExitCode.INVALID_ARGUMENTS,
308           String.format("Option '%s' not provided.",
309               option.hasLongOpt()
310                   ? "--" + option.getLongOpt()
311                   : "-" + option.getOpt()));
312     }
313     try {
314       return SchemaFormat.valueOf(toFormatText.toUpperCase(Locale.ROOT));
315     } catch (IllegalArgumentException ex) {
316       throw new CommandExecutionException(
317           ExitCode.INVALID_ARGUMENTS,
318           String.format("Invalid '%s' argument. The schema format must be one of: %s.",
319               option.hasLongOpt()
320                   ? "--" + option.getLongOpt()
321                   : "-" + option.getOpt(),
322               Arrays.stream(SchemaFormat.values())
323                   .map(Enum::name)
324                   .collect(CustomCollectors.joiningWithOxfordComma("or"))),
325           ex);
326     }
327   }
328 
329   /**
330    * Detect the source format for content identified using the provided option.
331    * <p>
332    * This method will first check if the source format is explicitly declared on
333    * the command line. If so, this format will be returned.
334    * <p>
335    * If not, then the content will be analyzed to determine the format.
336    *
337    * @param commandLine
338    *          the provided command line argument information
339    * @param option
340    *          the option specifying the format, which must be present on the
341    *          command line
342    * @param loader
343    *          the content loader to use to load the content instance
344    * @param resource
345    *          the resource to load
346    * @return the identified content format
347    * @throws CommandExecutionException
348    *           if an error occurred while determining the source format
349    * @since 2.0.0
350    */
351   @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.OnlyOneReturn" })
352   @NonNull
353   public static Format determineSourceFormat(
354       @NonNull CommandLine commandLine,
355       @NonNull Option option,
356       @NonNull IBoundLoader loader,
357       @NonNull URI resource) throws CommandExecutionException {
358     if (commandLine.hasOption(option)) {
359       // use the option
360       return getFormat(commandLine, option);
361     }
362 
363     // attempt to determine the format
364     try {
365       return loader.detectFormat(resource);
366     } catch (FileNotFoundException ex) {
367       // this case was already checked for
368       throw new CommandExecutionException(
369           ExitCode.IO_ERROR,
370           String.format("The provided source '%s' does not exist.", resource),
371           ex);
372     } catch (IOException ex) {
373       throw new CommandExecutionException(
374           ExitCode.IO_ERROR,
375           String.format("Unable to determine source format. Use '%s' to specify the format. %s",
376               option.hasLongOpt()
377                   ? "--" + option.getLongOpt()
378                   : "-" + option.getOpt(),
379               ex.getLocalizedMessage()),
380           ex);
381     }
382   }
383 
384   /**
385    * Load a Metaschema module based on the provided command line option.
386    *
387    * @param commandLine
388    *          the provided command line argument information
389    * @param option
390    *          the option specifying the module to load, which must be present on
391    *          the command line
392    * @param currentWorkingDirectory
393    *          the URI of the current working directory
394    * @param bindingContext
395    *          the context used to access Metaschema module information based on
396    *          Java class bindings
397    * @return the loaded module
398    * @throws CommandExecutionException
399    *           if an error occurred while loading the module
400    * @since 2.0.0
401    */
402   @NonNull
403   public static IModule loadModule(
404       @NonNull CommandLine commandLine,
405       @NonNull Option option,
406       @NonNull URI currentWorkingDirectory,
407       @NonNull IBindingContext bindingContext) throws CommandExecutionException {
408     String moduleName = commandLine.getOptionValue(option);
409     if (moduleName == null) {
410       throw new CommandExecutionException(
411           ExitCode.INVALID_ARGUMENTS,
412           String.format("Unable to determine the module to load. Use '%s' to specify the module.",
413               option.hasLongOpt()
414                   ? "--" + option.getLongOpt()
415                   : "-" + option.getOpt()));
416     }
417 
418     URI moduleUri;
419     try {
420       moduleUri = UriUtils.toUri(moduleName, currentWorkingDirectory);
421     } catch (URISyntaxException ex) {
422       throw new CommandExecutionException(
423           ExitCode.INVALID_ARGUMENTS,
424           String.format("Cannot load module as '%s' is not a valid file or URL. %s",
425               ex.getInput(),
426               ex.getLocalizedMessage()),
427           ex);
428     }
429     return loadModule(moduleUri, bindingContext);
430   }
431 
432   /**
433    * Load a Metaschema module from the provided relative resource path.
434    * <p>
435    * This method will resolve the provided resource against the current working
436    * directory to create an absolute URI.
437    *
438    * @param moduleResource
439    *          the relative path to the module resource to load
440    * @param currentWorkingDirectory
441    *          the URI of the current working directory
442    * @param bindingContext
443    *          the context used to access Metaschema module information based on
444    *          Java class bindings
445    * @return the loaded module
446    * @throws CommandExecutionException
447    *           if an error occurred while loading the module
448    * @since 2.0.0
449    */
450   @NonNull
451   public static IModule loadModule(
452       @NonNull String moduleResource,
453       @NonNull URI currentWorkingDirectory,
454       @NonNull IBindingContext bindingContext) throws CommandExecutionException {
455     try {
456       URI moduleUri = getResourceUri(
457           moduleResource,
458           currentWorkingDirectory);
459       return loadModule(moduleUri, bindingContext);
460     } catch (URISyntaxException ex) {
461       throw new CommandExecutionException(
462           ExitCode.INVALID_ARGUMENTS,
463           String.format("Cannot load module as '%s' is not a valid file or URL. %s",
464               ex.getInput(),
465               ex.getLocalizedMessage()),
466           ex);
467     }
468   }
469 
470   /**
471    * Load a Metaschema module from the provided resource path.
472    *
473    * @param moduleResource
474    *          the absolute path to the module resource to load
475    * @param bindingContext
476    *          the context used to access Metaschema module information based on
477    *          Java class bindings
478    * @return the loaded module
479    * @throws CommandExecutionException
480    *           if an error occurred while loading the module
481    * @since 2.0.0
482    */
483   @NonNull
484   public static IModule loadModule(
485       @NonNull URI moduleResource,
486       @NonNull IBindingContext bindingContext) throws CommandExecutionException {
487     // TODO: ensure the resource URI is absolute
488     try {
489       IBindingModuleLoader loader = bindingContext.newModuleLoader();
490       loader.allowEntityResolution();
491       return loader.load(moduleResource);
492     } catch (IOException | MetaschemaException ex) {
493       throw new CommandExecutionException(ExitCode.PROCESSING_ERROR, ex);
494     }
495   }
496 
497   /**
498    * For a given resource location, resolve the location into an absolute URI.
499    *
500    * @param location
501    *          the resource location
502    * @param currentWorkingDirectory
503    *          the URI of the current working directory
504    * @return the resolved URI
505    * @throws URISyntaxException
506    *           if the location is not a valid URI
507    */
508   @NonNull
509   public static URI getResourceUri(
510       @NonNull String location,
511       @NonNull URI currentWorkingDirectory) throws URISyntaxException {
512     return UriUtils.toUri(location, currentWorkingDirectory);
513   }
514 
515   /**
516    * Load a set of external Metaschema module constraints based on the provided
517    * command line option.
518    *
519    * @param commandLine
520    *          the provided command line argument information
521    * @param option
522    *          the option specifying the constraints to load, which must be present
523    *          on the command line
524    * @param currentWorkingDirectory
525    *          the URI of the current working directory
526    * @return the set of loaded constraints
527    * @throws CommandExecutionException
528    *           if an error occurred while loading the module
529    * @since 2.0.0
530    */
531   @NonNull
532   public static Set<IConstraintSet> loadConstraintSets(
533       @NonNull CommandLine commandLine,
534       @NonNull Option option,
535       @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
536     Set<IConstraintSet> constraintSets;
537     if (commandLine.hasOption(option)) {
538       IConstraintLoader constraintLoader = IBindingContext.getConstraintLoader();
539       constraintSets = new LinkedHashSet<>();
540       String[] args = commandLine.getOptionValues(option);
541       for (String arg : args) {
542         assert arg != null;
543         try {
544           URI constraintUri = ObjectUtils.requireNonNull(UriUtils.toUri(arg, currentWorkingDirectory));
545           constraintSets.addAll(constraintLoader.load(constraintUri));
546         } catch (URISyntaxException | IOException | MetaschemaException | MetapathException ex) {
547           throw new CommandExecutionException(
548               ExitCode.IO_ERROR,
549               String.format("Unable to process constraint set '%s'. %s",
550                   arg,
551                   ex.getLocalizedMessage()),
552               ex);
553         }
554       }
555     } else {
556       constraintSets = CollectionUtil.emptySet();
557     }
558     return constraintSets;
559   }
560 
561   /**
562    * Create a temporary directory for ephemeral files that will be deleted on
563    * shutdown.
564    *
565    * @return the temp directory path
566    * @throws IOException
567    *           if an error occurred while creating the temporary directory
568    */
569   @NonNull
570   public static Path newTempDir() throws IOException {
571     Path retval = Files.createTempDirectory("metaschema-cli-");
572     DeleteOnShutdown.register(retval);
573     return ObjectUtils.notNull(retval);
574   }
575 
576   /**
577    * Create a new {@link IBindingContext} that is configured for dynamic
578    * compilation.
579    *
580    * @return the binding context
581    * @throws CommandExecutionException
582    *           if an error occurred while creating the binding context
583    * @since 2.0.0
584    */
585   @NonNull
586   public static IBindingContext newBindingContextWithDynamicCompilation() throws CommandExecutionException {
587     return newBindingContextWithDynamicCompilation(CollectionUtil.emptySet());
588   }
589 
590   /**
591    * Create a new {@link IBindingContext} that is configured for dynamic
592    * compilation and to use the provided constraints.
593    *
594    * @param constraintSets
595    *          the Metaschema module constraints to dynamicly bind to loaded
596    *          modules
597    * @return the binding context
598    * @throws CommandExecutionException
599    *           if an error occurred while creating the binding context
600    * @since 2.0.0
601    */
602   @NonNull
603   public static IBindingContext newBindingContextWithDynamicCompilation(@NonNull Set<IConstraintSet> constraintSets)
604       throws CommandExecutionException {
605     try {
606       Path tempDir = newTempDir();
607       return IBindingContext.builder()
608           .compilePath(tempDir)
609           .constraintSet(constraintSets)
610           .build();
611     } catch (IOException ex) {
612       throw new CommandExecutionException(ExitCode.RUNTIME_ERROR,
613           String.format("Unable to initialize the binding context. %s", ex.getLocalizedMessage()),
614           ex);
615     }
616   }
617 
618   private MetaschemaCommands() {
619     // disable construction
620   }
621 }