command_mapper

CI Code Climate Gem Version

Description

Command Mapper maps an external command's options and arguments to Class attributes to allow safely and securely executing commands.

Features

  • Supports defining commands as Ruby classes.
  • Supports mapping in options and additional arguments.
    • Supports common option types:
    • Str: string values
    • Num: numeric values
    • Dec: decimal values
    • Hex: hexadecimal values
    • Map: maps Ruby values to other String values.
      • Map::YesNo: maps true/false to yes/no.
      • Map::EnabledDisabled: Maps true/false to enabled/disabled.
    • Enum: maps a finite set of Symbols to a finite set of Strings (aka --opt={foo|bar|baz} values).
    • List: comma-separated list (aka --opt VALUE,...).
    • KeyValue: maps a Hash or Array to key:value Strings (aka --opt KEY:VALUE or --opt KEY=VALUE values).
    • KeyValueList: a key-value list (aka --opt KEY:VALUE,... or --opt KEY=VALUE;... values).
    • InputPath: a path to a pre-existing file or directory
    • InputFile: a path to a pre-existing file
    • InputDir: a path to a pre-existing directory
  • Supports mapping in sub-commands.
  • Allows running the command via IO.popen to read the command's output.
  • Allows running commands with additional environment variables.
  • Allows overriding the command name or path to the command.
  • Allows running commands via sudo.
  • Prevents command injection and option injection.

Examples

require 'command_mapper/command'

#
# Represents the `grep` command
#
class Grep < CommandMapper::Command

  command "grep" do
    option "--extended-regexp"
    option "--fixed-strings"
    option "--basic-regexp"
    option "--perl-regexp"
    option "--regexp", equals: true, value: true
    option "--file", name: :patterns_file, equals: true, value: true
    option "--ignore-case"
    option "--no-ignore-case"
    option "--word-regexp"
    option "--line-regexp"
    option "--null-data"
    option "--no-messages"
    option "--invert-match"
    option "--version"
    option "--help"
    option "--max-count", equals: true, value: {type: Num.new}
    option "--byte-offset"
    option "--line-number"
    option "--line-buffered"
    option "--with-filename"
    option "--no-filename"
    option "--label", equals: true, value: true
    option "--only-matching"
    option "--quiet"
    option "--binary-files", equals: true, value: true
    option "--text"
    option "-I", name:  # FIXME: name
    option "--directories", equals: true, value: true
    option "--devices", equals: true, value: true
    option "--recursive"
    option "--dereference-recursive"
    option "--include", equals: true, value: true
    option "--exclude", equals: true, value: true
    option "--exclude-from", equals: true, value: true
    option "--exclude-dir", equals: true, value: true
    option "--files-without-match", value: true
    option "--files-with-matches"
    option "--count"
    option "--initial-tab"
    option "--null"
    option "--before-context", equals: true, value: {type: Num.new}
    option "--after-context", equals: true, value: {type: Num.new}
    option "--context", equals: true, value: {type: Num.new}
    option "--group-separator", equals: true, value: true
    option "--no-group-separator"
    option "--color", equals: :optional, value: {required: false}
    option "--colour", equals: :optional, value: {required: false}
    option "--binary"

    argument :patterns
    argument :file, required: false, repeats: true
  end

end

Defining Options

option "--opt"

Define a short option:

option "-o", name: :opt

Defines an option with a required value:

option "--output", value: {required: true}

Defines an option that uses an equals sign (ex: --output=value):

option "--output", equals: true, value: {required: true}

Defines an option where the value is embedded into the flag (ex: -Ivalue):

option "-I", value: {required: true}, value_in_flag: true

Defines an option that can be specified multiple times:

option "--include-dir", repeats: true

Defines an option that accepts a numeric value:

option "--count", value: {type: Num.new}

Define an option that only accepts a range of acceptable values:

option "--count", value: {type: Num.new(range: 1..100)}

Defines an option that accepts a comma-separated list:

option "--list", value: {type: List.new}

Defines an option that accepts a key=value pair:

option "--param", value: {type: KeyValue.new}

Defines an option that accepts a key:value pair:

option "--param", value: {type: KeyValue.new(separator: ':')}

Defines an option that accepts a finite number of values:

option "--type", value: {type: Enum[:foo, :bar, :baz]}

Custom methods:

def foo
  @foo || @bar
end

def foo=(value)
  @foo = case value
         when Hash  then ...
         when Array then ...
         else            value.to_s
         end
end

Defining Arguments

argument :host

Define an optional argument:

argument :optional_output, required: false

Define an argument that can be repeated:

argument :files, repeats: true

Define an argument that accepts an existing file:

argument :file, type: InputFile.new

Define an argument that accepts an existing directory:

argument :dir, type: InputDir.new

Custom methods:

def foo
  @foo || @bar
end

def foo=(value)
  @foo = case value
         when Hash  then ...
         when Array then ...
         else            value.to_s
         end
end

Custom Types

class PortRange < CommandMapper::Types::Type

  def validate(value)
    case value
    when Integer
      true
    when Range
      if value.begin.kind_of?(Integer)
        true
      else
        [false, "port range can only contain Integers"]
      end
    else
      [false, "port range must be an Integer or a Range of Integers"]
    end
  end

  def format(value)
    case value
    when Integer
      "#{value}"
    when Range
      "#{value.begin}-#{value.end}"
    end
  end

end

option :ports, value: {required: true, type: PortRange.new}

Running

Keyword arguments:

Grep.run(ignore_case: true, patterns: "foo", file: "file.txt")
# ...

With a block:

Grep.run do |grep|
  grep.ignore_case = true
  grep.patterns    = "foo"
  grep.file        = "file.txt"
end

Overriding the command name:

Grep.run(..., command_name: 'grep2')

Specifying the direct path to the command:

Grep.run(..., command_path: '/path/to/grep')

Capturing output

Grep.capture(ignore_case: true, patterns: "foo", file: "file.txt")
# => "..."

popen

io = Grep.popen(ignore_case: true, patterns: "foo", file: "file.txt")

io.each_line do |line|
  # ...
end

sudo

Grep.sudo(patterns: "Error", file: "/var/log/syslog")
# Password: 
# ...

Defining sub-commands

module Git
  class Command < CommandMapper::Command

    command 'git' do
      option "--version"
      option "--help"
      option "-C", name: :dir, value: {type: InputDir.new}
      # ...

      subcommand :clone do
        option "--bare"
        option "--mirror"
        option "--depth", value: {type: Num.new}
        # ...

        argument :repository
        argument :directory, required: false
      end

      # ...
    end

  end
end

Invoking sub-commands

Git::Command.run(clone: {repository: 'https://github.com/user/repo.git'})

Code Gen

command_mapper-gen can automatically generate command classes from a command's --help output and/or man page.

$ gem install command_mapper-gen
$ command_mapper-gen cat
require 'command_mapper/command'

#
# Represents the `cat` command
#
class Cat < CommandMapper::Command

  command "cat" do
    option "--show-all"
    option "--number-nonblank"
    option "-e", name:  # FIXME: name
    option "--show-ends"
    option "--number"
    option "--squeeze-blank"
    option "-t", name:  # FIXME: name
    option "--show-tabs"
    option "-u", name:  # FIXME: name
    option "--show-nonprinting"
    option "--help"
    option "--version"

    argument :file, required: false, repeats: true
  end

end

Real-World Examples

Requirements

Install

$ gem install command_mapper

Gemfile

gem 'command_mapper', '~> 0.2'

gemspec

gemspec.add_dependency 'command_mapper', '~> 0.2'

Alternatives

License

Copyright (c) 2021-2022 Hal Brodigan

See LICENSE for license information.