1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.metaschema.cli.processor;
7   
8   import static org.fusesource.jansi.Ansi.ansi;
9   
10  import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
11  import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
12  import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
13  import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
14  import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
15  import gov.nist.secauto.metaschema.core.util.AutoCloser;
16  import gov.nist.secauto.metaschema.core.util.CollectionUtil;
17  import gov.nist.secauto.metaschema.core.util.IVersionInfo;
18  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
19  
20  import org.apache.commons.cli.CommandLine;
21  import org.apache.commons.cli.CommandLineParser;
22  import org.apache.commons.cli.DefaultParser;
23  import org.apache.commons.cli.HelpFormatter;
24  import org.apache.commons.cli.Option;
25  import org.apache.commons.cli.Options;
26  import org.apache.commons.cli.ParseException;
27  import org.apache.logging.log4j.Level;
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.apache.logging.log4j.core.LoggerContext;
31  import org.apache.logging.log4j.core.config.Configuration;
32  import org.apache.logging.log4j.core.config.LoggerConfig;
33  import org.fusesource.jansi.AnsiConsole;
34  import org.fusesource.jansi.AnsiPrintStream;
35  
36  import java.io.PrintStream;
37  import java.io.PrintWriter;
38  import java.nio.charset.StandardCharsets;
39  import java.util.Arrays;
40  import java.util.Collection;
41  import java.util.LinkedList;
42  import java.util.List;
43  import java.util.Map;
44  import java.util.concurrent.atomic.AtomicBoolean;
45  import java.util.function.Function;
46  import java.util.stream.Collectors;
47  
48  import edu.umd.cs.findbugs.annotations.NonNull;
49  import edu.umd.cs.findbugs.annotations.Nullable;
50  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
51  
52  /**
53   * Processes command line arguments and dispatches called commands.
54   * <p>
55   * This implementation make significant use of the command pattern to support a
56   * delegation chain of commands based on implementations of {@link ICommand}.
57   */
58  @SuppressWarnings("PMD.CouplingBetweenObjects")
59  public class CLIProcessor {
60    private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
61  
62    /**
63     * This option indicates if the help should be shown.
64     */
65    @NonNull
66    public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h")
67        .longOpt("help")
68        .desc("display this help message")
69        .build());
70    /**
71     * This option indicates if colorized output should be disabled.
72     */
73    @NonNull
74    public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder()
75        .longOpt("no-color")
76        .desc("do not colorize output")
77        .build());
78    /**
79     * This option indicates if non-errors should be suppressed.
80     */
81    @NonNull
82    public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q")
83        .longOpt("quiet")
84        .desc("minimize output to include only errors")
85        .build());
86    /**
87     * This option indicates if a strack trace should be shown for an error
88     * {@link ExitStatus}.
89     */
90    @NonNull
91    public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder()
92        .longOpt("show-stack-trace")
93        .desc("display the stack trace associated with an error")
94        .build());
95    /**
96     * This option indicates if the version information should be shown.
97     */
98    @NonNull
99    public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder()
100       .longOpt("version")
101       .desc("display the application version")
102       .build());
103 
104   @NonNull
105   private static final List<Option> OPTIONS = ObjectUtils.notNull(List.of(
106       HELP_OPTION,
107       NO_COLOR_OPTION,
108       QUIET_OPTION,
109       SHOW_STACK_TRACE_OPTION,
110       VERSION_OPTION));
111 
112   /**
113    * Used to identify the version info for the command.
114    */
115   public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
116 
117   @NonNull
118   private final List<ICommand> commands = new LinkedList<>();
119   @NonNull
120   private final String exec;
121   @NonNull
122   private final Map<String, IVersionInfo> versionInfos;
123 
124   /**
125    * The main entry point for command execution.
126    *
127    * @param args
128    *          the command line arguments to process
129    */
130   public static void main(String... args) {
131     System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
132     CLIProcessor processor = new CLIProcessor("metaschema-cli");
133 
134     CommandService.getInstance().getCommands().stream().forEach(command -> {
135       assert command != null;
136       processor.addCommandHandler(command);
137     });
138     System.exit(processor.process(args).getExitCode().getStatusCode());
139   }
140 
141   /**
142    * The main entry point for CLI processing.
143    * <p>
144    * This uses the build-in version information.
145    *
146    * @param args
147    *          the command line arguments
148    */
149   public CLIProcessor(@NonNull String args) {
150     this(args, CollectionUtil.singletonMap(COMMAND_VERSION, new ProcessorVersion()));
151   }
152 
153   /**
154    * The main entry point for CLI processing.
155    * <p>
156    * This uses the provided version information.
157    *
158    * @param exec
159    *          the command name
160    * @param versionInfos
161    *          the version info to display when the version option is provided
162    */
163   public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
164     this.exec = exec;
165     this.versionInfos = versionInfos;
166     AnsiConsole.systemInstall();
167   }
168 
169   /**
170    * Gets the command used to execute for use in help text.
171    *
172    * @return the command name
173    */
174   @NonNull
175   public String getExec() {
176     return exec;
177   }
178 
179   /**
180    * Retrieve the version information for this application.
181    *
182    * @return the versionInfo
183    */
184   @NonNull
185   public Map<String, IVersionInfo> getVersionInfos() {
186     return versionInfos;
187   }
188 
189   /**
190    * Register a new command handler.
191    *
192    * @param handler
193    *          the command handler to register
194    */
195   public void addCommandHandler(@NonNull ICommand handler) {
196     commands.add(handler);
197   }
198 
199   /**
200    * Process a set of CLIProcessor arguments.
201    * <p>
202    * process().getExitCode().getStatusCode()
203    *
204    * @param args
205    *          the arguments to process
206    * @return the exit status
207    */
208   @NonNull
209   public ExitStatus process(String... args) {
210     return parseCommand(args);
211   }
212 
213   @NonNull
214   private ExitStatus parseCommand(String... args) {
215     List<String> commandArgs = Arrays.asList(args);
216     assert commandArgs != null;
217     CallingContext callingContext = new CallingContext(commandArgs);
218 
219     if (LOGGER.isDebugEnabled()) {
220       String commandChain = callingContext.getCalledCommands().stream()
221           .map(ICommand::getName)
222           .collect(Collectors.joining(" -> "));
223       LOGGER.debug("Processing command chain: {}", commandChain);
224     }
225 
226     ExitStatus status;
227     // the first two arguments should be the <command> and <operation>, where <type>
228     // is the object type
229     // the <operation> is performed against.
230     if (commandArgs.isEmpty()) {
231       status = ExitCode.INVALID_COMMAND.exit();
232       callingContext.showHelp();
233     } else {
234       status = callingContext.processCommand();
235     }
236     return status;
237   }
238 
239   /**
240    * Get the root-level commands.
241    *
242    * @return the list of commands
243    */
244   @NonNull
245   protected final List<ICommand> getTopLevelCommands() {
246     return CollectionUtil.unmodifiableList(commands);
247   }
248 
249   /**
250    * Get the root-level commands, mapped from name to command.
251    *
252    * @return the map of command names to command
253    */
254   @NonNull
255   protected final Map<String, ICommand> getTopLevelCommandsByName() {
256     return ObjectUtils.notNull(getTopLevelCommands()
257         .stream()
258         .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
259   }
260 
261   private static void handleNoColor() {
262     System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
263     AnsiConsole.systemUninstall();
264   }
265 
266   /**
267    * Configure the logger to only report errors.
268    */
269   public static void handleQuiet() {
270     LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here
271     Configuration config = ctx.getConfiguration();
272     LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
273     Level oldLevel = loggerConfig.getLevel();
274     if (oldLevel.isLessSpecificThan(Level.ERROR)) {
275       loggerConfig.setLevel(Level.ERROR);
276       ctx.updateLoggers();
277     }
278   }
279 
280   /**
281    * Output version information.
282    */
283   protected void showVersion() {
284     @SuppressWarnings("resource")
285     PrintStream out = AnsiConsole.out(); // NOPMD - not owner
286     getVersionInfos().values().stream().forEach(info -> {
287       out.println(ansi()
288           .bold().a(info.getName()).boldOff()
289           .a(" ")
290           .bold().a(info.getVersion()).boldOff()
291           .a(" built at ")
292           .bold().a(info.getBuildTimestamp()).boldOff()
293           .a(" from branch ")
294           .bold().a(info.getGitBranch()).boldOff()
295           .a(" (")
296           .bold().a(info.getGitCommit()).boldOff()
297           .a(") at ")
298           .bold().a(info.getGitOriginUrl()).boldOff()
299           .reset());
300     });
301     out.flush();
302   }
303 
304   /**
305    * Records information about the command line options and called command
306    * hierarchy.
307    */
308   @SuppressWarnings("PMD.GodClass")
309   public final class CallingContext {
310     @NonNull
311     private final List<Option> options;
312     @NonNull
313     private final List<ICommand> calledCommands;
314     @Nullable
315     private final ICommand targetCommand;
316     @NonNull
317     private final List<String> extraArgs;
318 
319     @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
320     private CallingContext(@NonNull List<String> args) {
321       @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast
322       LinkedList<ICommand> calledCommands = new LinkedList<>();
323       List<Option> options = new LinkedList<>(OPTIONS);
324       List<String> extraArgs = new LinkedList<>();
325 
326       AtomicBoolean endArgs = new AtomicBoolean();
327       args.forEach(arg -> {
328         if (endArgs.get() || arg.startsWith("-")) {
329           extraArgs.add(arg);
330         } else if ("--".equals(arg)) {
331           endArgs.set(true);
332         } else {
333           ICommand command = calledCommands.isEmpty()
334               ? getTopLevelCommandsByName().get(arg)
335               : calledCommands.getLast().getSubCommandByName(arg);
336 
337           if (command == null) {
338             extraArgs.add(arg);
339             endArgs.set(true);
340           } else {
341             calledCommands.add(command);
342             options.addAll(command.gatherOptions());
343           }
344         }
345       });
346 
347       this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
348       this.targetCommand = calledCommands.peekLast();
349       this.options = CollectionUtil.unmodifiableList(options);
350       this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
351     }
352 
353     /**
354      * Get the command line processor instance that generated this calling context.
355      *
356      * @return the instance
357      */
358     @NonNull
359     public CLIProcessor getCLIProcessor() {
360       return CLIProcessor.this;
361     }
362 
363     /**
364      * Get the command that was triggered by the CLI arguments.
365      *
366      * @return the command or {@code null} if no command was triggered
367      */
368     @Nullable
369     public ICommand getTargetCommand() {
370       return targetCommand;
371     }
372 
373     /**
374      * Get the options that are in scope for the current command context.
375      *
376      * @return the list of options
377      */
378     @NonNull
379     private List<Option> getOptionsList() {
380       return options;
381     }
382 
383     @NonNull
384     private List<ICommand> getCalledCommands() {
385       return calledCommands;
386     }
387 
388     /**
389      * Get any left over arguments that were not consumed by CLI options.
390      *
391      * @return the list of remaining arguments, which may be empty
392      */
393     @NonNull
394     private List<String> getExtraArgs() {
395       return extraArgs;
396     }
397 
398     /**
399      * Get the collections of in scope options as an options group.
400      *
401      * @return the options group
402      */
403     private Options toOptions() {
404       Options retval = new Options();
405       for (Option option : getOptionsList()) {
406         retval.addOption(option);
407       }
408       return retval;
409     }
410 
411     /**
412      * Process the command identified by the CLI arguments.
413      *
414      * @return the result of processing the command
415      */
416     @SuppressWarnings({
417         "PMD.OnlyOneReturn",
418         "PMD.NPathComplexity",
419         "PMD.CyclomaticComplexity"
420     })
421     @NonNull
422     public ExitStatus processCommand() {
423       // TODO: Consider refactoring as follows to reduce complexity:
424       // - Extract the parsing logic for each phase into separate methods (e.g.,
425       // parsePhaseOneOptions, parsePhaseTwoOptions).
426       // - Encapsulate error handling into dedicated methods.
427       // - Separate command execution logic into its own method if possible.
428       CommandLineParser parser = new DefaultParser();
429 
430       // this uses a three phase approach where:
431       // phase 1: checks if help or version are used
432       // phase 2: parse and validate arguments
433       // phase 3: executes the command
434 
435       // phase 1
436       CommandLine cmdLine;
437       try {
438         Options phase1Options = new Options();
439         phase1Options.addOption(HELP_OPTION);
440         phase1Options.addOption(VERSION_OPTION);
441 
442         cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true));
443       } catch (ParseException ex) {
444         String msg = ex.getMessage();
445         assert msg != null;
446         return handleInvalidCommand(msg);
447       }
448 
449       if (cmdLine.hasOption(VERSION_OPTION)) {
450         showVersion();
451         return ExitCode.OK.exit();
452       }
453       if (cmdLine.hasOption(HELP_OPTION)) {
454         showHelp();
455         return ExitCode.OK.exit();
456       }
457 
458       // phase 2
459       try {
460         cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0])));
461       } catch (ParseException ex) {
462         String msg = ex.getMessage();
463         assert msg != null;
464         return handleInvalidCommand(msg);
465       }
466 
467       ICommand targetCommand = getTargetCommand();
468       if (targetCommand != null) {
469         try {
470           targetCommand.validateExtraArguments(this, cmdLine);
471         } catch (InvalidArgumentException ex) {
472           return handleError(
473               ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()),
474               cmdLine,
475               true);
476         }
477       }
478 
479       for (ICommand cmd : getCalledCommands()) {
480         try {
481           cmd.validateOptions(this, cmdLine);
482         } catch (InvalidArgumentException ex) {
483           String msg = ex.getMessage();
484           assert msg != null;
485           return handleInvalidCommand(msg);
486         }
487       }
488 
489       // phase 3
490       if (cmdLine.hasOption(NO_COLOR_OPTION)) {
491         handleNoColor();
492       }
493 
494       if (cmdLine.hasOption(QUIET_OPTION)) {
495         handleQuiet();
496       }
497       return invokeCommand(cmdLine);
498     }
499 
500     /**
501      * Directly execute the logic associated with the command.
502      *
503      * @param cmdLine
504      *          the command line information
505      * @return the result of executing the command
506      */
507     @SuppressWarnings({
508         "PMD.OnlyOneReturn", // readability
509         "PMD.AvoidCatchingGenericException" // needed here
510     })
511     @NonNull
512     private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
513       ExitStatus retval;
514       try {
515         ICommand targetCommand = getTargetCommand();
516         if (targetCommand == null) {
517           retval = ExitCode.INVALID_COMMAND.exit();
518         } else {
519           ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
520           try {
521             executor.execute();
522             retval = ExitCode.OK.exit();
523           } catch (CommandExecutionException ex) {
524             retval = ex.toExitStatus();
525           } catch (RuntimeException ex) {
526             retval = ExitCode.RUNTIME_ERROR
527                 .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage())
528                 .withThrowable(ex);
529           }
530         }
531       } catch (RuntimeException ex) {
532         retval = ExitCode.RUNTIME_ERROR
533             .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
534             .withThrowable(ex);
535       }
536 
537       if (!ExitCode.OK.equals(retval.getExitCode())) {
538         retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
539 
540         if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
541           showHelp();
542         }
543       }
544       return retval;
545     }
546 
547     /**
548      * Handle an error that occurred while executing the command.
549      *
550      * @param exitStatus
551      *          the execution result
552      * @param cmdLine
553      *          the command line information
554      * @param showHelp
555      *          if {@code true} show the help information
556      * @return the resulting exit status
557      */
558     @NonNull
559     public ExitStatus handleError(
560         @NonNull ExitStatus exitStatus,
561         @NonNull CommandLine cmdLine,
562         boolean showHelp) {
563       exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
564       if (showHelp) {
565         showHelp();
566       }
567       return exitStatus;
568     }
569 
570     /**
571      * Generate the help message and exit status for an invalid command using the
572      * provided message.
573      *
574      * @param message
575      *          the error message
576      * @return the resulting exit status
577      */
578     @NonNull
579     public ExitStatus handleInvalidCommand(
580         @NonNull String message) {
581       showHelp();
582 
583       ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
584       retval.generateMessage(false);
585       return retval;
586     }
587 
588     /**
589      * Callback for providing a help header.
590      *
591      * @return the header or {@code null}
592      */
593     @Nullable
594     private String buildHelpHeader() {
595       // TODO: build a suitable header
596       return null;
597     }
598 
599     /**
600      * Callback for providing a help footer.
601      *
602      * @param exec
603      *          the executable name
604      *
605      * @return the footer or {@code null}
606      */
607     @NonNull
608     private String buildHelpFooter() {
609 
610       ICommand targetCommand = getTargetCommand();
611       Collection<ICommand> subCommands;
612       if (targetCommand == null) {
613         subCommands = getTopLevelCommands();
614       } else {
615         subCommands = targetCommand.getSubCommands();
616       }
617 
618       String retval;
619       if (subCommands.isEmpty()) {
620         retval = "";
621       } else {
622         StringBuilder builder = new StringBuilder(128);
623         builder
624             .append(System.lineSeparator())
625             .append("The following are available commands:")
626             .append(System.lineSeparator());
627 
628         int length = subCommands.stream()
629             .mapToInt(command -> command.getName().length())
630             .max().orElse(0);
631 
632         for (ICommand command : subCommands) {
633           builder.append(
634               ansi()
635                   .render(String.format("   @|bold %-" + length + "s|@ %s%n",
636                       command.getName(),
637                       command.getDescription())));
638         }
639         builder
640             .append(System.lineSeparator())
641             .append('\'')
642             .append(getExec())
643             .append(" <command> --help' will show help on that specific command.")
644             .append(System.lineSeparator());
645         retval = builder.toString();
646         assert retval != null;
647       }
648       return retval;
649     }
650 
651     /**
652      * Get the CLI syntax.
653      *
654      * @return the CLI syntax to display in help output
655      */
656     private String buildHelpCliSyntax() {
657 
658       StringBuilder builder = new StringBuilder(64);
659       builder.append(getExec());
660 
661       List<ICommand> calledCommands = getCalledCommands();
662       if (!calledCommands.isEmpty()) {
663         builder.append(calledCommands.stream()
664             .map(ICommand::getName)
665             .collect(Collectors.joining(" ", " ", "")));
666       }
667 
668       // output calling commands
669       ICommand targetCommand = getTargetCommand();
670       if (targetCommand == null) {
671         builder.append(" <command>");
672       } else {
673         builder.append(getSubCommands(targetCommand));
674       }
675 
676       // output required options
677       getOptionsList().stream()
678           .filter(Option::isRequired)
679           .forEach(option -> {
680             builder
681                 .append(' ')
682                 .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
683             if (option.hasArg()) {
684               builder
685                   .append('=')
686                   .append(option.getArgName());
687             }
688           });
689 
690       // output non-required option placeholder
691       builder.append(" [<options>]");
692 
693       // output extra arguments
694       if (targetCommand != null) {
695         // handle extra arguments
696         builder.append(getExtraArguments(targetCommand));
697       }
698 
699       String retval = builder.toString();
700       assert retval != null;
701       return retval;
702     }
703 
704     @NonNull
705     private CharSequence getSubCommands(ICommand targetCommand) {
706       Collection<ICommand> subCommands = targetCommand.getSubCommands();
707 
708       StringBuilder builder = new StringBuilder();
709       if (!subCommands.isEmpty()) {
710         builder.append(' ');
711         if (!targetCommand.isSubCommandRequired()) {
712           builder.append('[');
713         }
714 
715         builder.append("<command>");
716 
717         if (!targetCommand.isSubCommandRequired()) {
718           builder.append(']');
719         }
720       }
721       return builder;
722     }
723 
724     @NonNull
725     private CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
726       StringBuilder builder = new StringBuilder();
727       for (ExtraArgument argument : targetCommand.getExtraArguments()) {
728         builder.append(' ');
729         if (!argument.isRequired()) {
730           builder.append('[');
731         }
732 
733         builder.append('<')
734             .append(argument.getName())
735             .append('>');
736 
737         if (argument.getNumber() > 1) {
738           builder.append("...");
739         }
740 
741         if (!argument.isRequired()) {
742           builder.append(']');
743         }
744       }
745       return builder;
746     }
747 
748     /**
749      * Output the help text to the console.
750      */
751     public void showHelp() {
752 
753       HelpFormatter formatter = new HelpFormatter();
754       formatter.setLongOptSeparator("=");
755 
756       @SuppressWarnings("resource")
757       AnsiPrintStream out = AnsiConsole.out();
758 
759       try (PrintWriter writer = new PrintWriter( // NOPMD not owned
760           AutoCloser.preventClose(out),
761           true,
762           StandardCharsets.UTF_8)) {
763         formatter.printHelp(
764             writer,
765             Math.max(out.getTerminalWidth(), 50),
766             buildHelpCliSyntax(),
767             buildHelpHeader(),
768             toOptions(),
769             HelpFormatter.DEFAULT_LEFT_PAD,
770             HelpFormatter.DEFAULT_DESC_PAD,
771             buildHelpFooter(),
772             false);
773         writer.flush();
774       }
775     }
776   }
777 }