lib/ronin/core/cli/command_shell.rb
# frozen_string_literal: true
#
# Copyright (c) 2021-2024 Hal Brodigan (postmodern.mod3 at gmail.com)
#
# ronin-core is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ronin-core is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with ronin-core. If not, see <https://www.gnu.org/licenses/>.
#
require_relative 'shell'
require_relative 'command_shell/command'
require 'shellwords'
module Ronin
module Core
module CLI
#
# Base class for all custom command shells.
#
# ## Example
#
# class HTTPShell < Ronin::Core::CLI::Shell
#
# shell_name 'http'
#
# command :get, usage: 'PATH [HEADERS...]',
# summary: 'Sends a GET request'
# def get(path,*headers)
# # ...
# end
#
# command :post, usage: 'PATH DATA [HEADERS...]',
# summary: 'Sends a POST request'
# def post(path,data,*headers)
# # ...
# end
#
# end
#
# HTTPShell.start
# # http> get /foo
#
# @api semipublic
#
class CommandShell < Shell
#
# The registered shell commands.
#
# @return [Hash{String => CommandShell::Command}]
# The registered shell commands.
#
def self.commands
@commands ||= if superclass <= CommandShell
superclass.commands.dup
else
{}
end
end
#
# Registers a shell command.
#
# @param [Symbol] name
# The name of the shell command.
#
# @param [Symbol] method_name
# Optional method name to use. Defaults to the name argument.
#
# @param [String, nil] usage
# A usage string indicating the shell command's options/arguments.
#
# @param [Array<String>, Symbol, nil] completions
# The possible tab completion values, or a method name, to complete
# the command's arguments.
#
# @param [String] summary
# A one-line summary of the shell command.
#
# @param [String] help
# Multi-line help output for the shell command.
#
def self.command(name, method_name: name,
usage: nil,
completions: nil,
summary: ,
help: summary)
commands[name.to_s] = Command.new(name, method_name: method_name,
usage: usage,
completions: completions,
summary: summary,
help: help.strip)
end
#
# Parses a line of input.
#
# @param [String] line
# A line of input.
#
# @return [String, Array<String>]
# The command name and any additional arguments.
#
def self.parse_command(line)
Shellwords.shellsplit(line)
end
#
# The partially input being tab completed.
#
# @param [String] word
# The partial input being tab completed.
#
# @param [String] preposing
# The optional command name that precedes the argument that's being
# tab completed.
#
# @return [Array<String>, nil]
# The possible completion values.
#
# @raise [NotImplementedError]
# The command defined a completion method name but the command shell
# does not define the complete method name.
#
def complete(word,preposing)
if preposing.empty?
self.class.commands.keys.select { |name| name.start_with?(word) }
else
name = preposing.split(/\s+/,2).first
if (command = self.class.commands[name])
completions = case command.completions
when Array then command.completions
when Symbol
unless respond_to?(command.completions)
raise(NotImplementedError,"#{self.class}##{command.completions} was not defined")
end
send(command.completions,word,preposing)
end
if completions
completions.select { |arg| arg.start_with?(word) }
end
end
end
end
#
# Executes a command.
#
# @param [String] command
# The command to execute.
#
# @return [Boolean]
# Indicates whether the command was successfully executed.
#
def exec(command)
call(*self.class.parse_command(command))
end
#
# Invokes the command with the matching name.
#
# @param [String] name
# The command name.
#
# @param [Array<String>] args
# Additional arguments for the command.
#
# @return [Boolean]
# Indicates whether the command was successfully executed.
#
# @raise [NotImplementedError]
# The method for the command was not defined.
#
def call(name,*args)
unless (command = self.class.commands[name])
return command_missing(name,*args)
end
method_name = command.method_name
unless respond_to?(method_name,false)
raise(NotImplementedError,"#{self.class}##{method_name} was not defined for the #{name.inspect} command")
end
unless method_arity_check(method_name,args)
return false
end
begin
send(method_name,*args)
rescue => error
print_exception(error)
print_error "an unhandled exception occurred in the #{name} command"
return false
end
return true
end
#
# Default method that is called when an unknown command is called.
#
# @param [String] name
#
# @param [Array<String>] args
#
def command_missing(name,*args)
command_not_found(name)
return false
end
#
# Prints an error message when an unknown command is given.
#
# @param [String] name
#
def command_not_found(name)
print_error "unknown command: #{name}"
end
command :help, usage: '[COMMAND]',
summary: 'Prints the list of commands or additional help'
#
# Prints all commands or help information for the given command.
#
# @param [String, nil] command
# Optional command name to print help information for.
#
def help(command=nil)
if command then help_command(command)
else help_commands
end
end
command :quit, summary: 'Exits the shell'
#
# Quits the shell.
#
# @since 0.2.0
#
def quit
exit
end
private
#
# Prints a list of all registered commands.
#
def help_commands
command_table = self.class.commands.map do |name,command|
[command.to_s, command.summary]
end
max_command_string = command_table.map { |command_string,summary|
command_string.length
}.max
command_table.each do |command_string,summary|
puts " #{command_string.ljust(max_command_string)}\t#{summary}"
end
end
#
# Prints help information about a specific command.
#
# @param [String] name
# The given command name.
#
def help_command(name)
unless (command = self.class.commands[name])
print_error "help: unknown command: #{name}"
return
end
puts "usage: #{command}"
if command.help
puts
puts command.help
end
end
#
# Calculates the minimum and maximum number of arguments for a given
# command method.
#
# @param [String] name
# The method name.
#
# @return [(Integer, Integer)]
# The minimum and maximum number of arguments for the method.
#
def minimum_maximum_args(name)
minimum = maximum = 0
method(name).parameters.each do |(type,arg)|
case type
when :req
minimum += 1
maximum += 1
when :opt then maximum += 1
when :rest then maximum = Float::INFINITY
end
end
return minimum, maximum
end
#
# Performs an arity check between the method's number of arguments and
# the number of arguments given.
#
# @param [String] name
# The method name to lookup.
#
# @param [Array<String>] args
# The given arguments.
#
# @return [Boolean]
# Indicates whether the method can accept the given number of
# arguments.
#
def method_arity_check(name,args)
minimum_args, maximum_args = minimum_maximum_args(name)
if args.length > maximum_args
print_error "#{name}: too many arguments given"
return false
elsif args.length < minimum_args
print_error "#{name}: too few arguments given"
return false
end
return true
end
end
end
end
end