module Net; module SSH

  # The Net::SSH::Config class is used to parse OpenSSH configuration files,
  # and translates that syntax into the configuration syntax that Net::SSH
  # understands. This lets Net::SSH scripts read their configuration (to
  # some extent) from OpenSSH configuration files (~/.ssh/config, /etc/ssh_config,
  # and so forth).
  #
  # Only a subset of OpenSSH configuration options are understood:
  #
  # * ChallengeResponseAuthentication => maps to the :auth_methods option challenge-response (then coleasced into keyboard-interactive)
  # * KbdInteractiveAuthentication => maps to the :auth_methods keyboard-interactive
  # * Ciphers => maps to the :encryption option
  # * Compression => :compression
  # * CompressionLevel => :compression_level
  # * ConnectTimeout => maps to the :timeout option
  # * ForwardAgent => :forward_agent
  # * GlobalKnownHostsFile => :global_known_hosts_file
  # * HostBasedAuthentication => maps to the :auth_methods option
  # * HostKeyAlgorithms => maps to :host_key option
  # * HostKeyAlias => :host_key_alias
  # * HostName => :host_name
  # * IdentityFile => maps to the :keys option
  # * IdentitiesOnly => :keys_only
  # * Macs => maps to the :hmac option
  # * PasswordAuthentication => maps to the :auth_methods option password
  # * Port => :port
  # * PreferredAuthentications => maps to the :auth_methods option
  # * ProxyCommand => maps to the :proxy option
  # * ProxyJump => maps to the :proxy option
  # * PubKeyAuthentication => maps to the :auth_methods option
  # * RekeyLimit => :rekey_limit
  # * User => :user
  # * UserKnownHostsFile => :user_known_hosts_file
  # * NumberOfPasswordPrompts => :number_of_password_prompts
  #
  # Note that you will never need to use this class directly--you can control
  # whether the OpenSSH configuration files are read by passing the :config
  # option to Net::SSH.start. (They are, by default.)
  class Config
    class << self
      @@default_files = %w(~/.ssh/config /etc/ssh_config /etc/ssh/ssh_config)
      # The following defaults follow the openssh client ssh_config defaults.
      # http://lwn.net/Articles/544640/
      # "hostbased" is off and "none" is not supported but we allow it since
      # it's used by some clients to query the server for allowed auth methods
      @@default_auth_methods = %w(none publickey password keyboard-interactive)

      # Returns an array of locations of OpenSSH configuration files
      # to parse by default.
      def default_files
        @@default_files
      end

      def default_auth_methods
        @@default_auth_methods
      end

      # Loads the configuration data for the given +host+ from all of the
      # given +files+ (defaulting to the list of files returned by
      # #default_files), translates the resulting hash into the options
      # recognized by Net::SSH, and returns them.
      def for(host, files=expandable_default_files)
        translate(files.inject({}) { |settings, file|
          load(file, host, settings)
        })
      end

      # Load the OpenSSH configuration settings in the given +file+ for the
      # given +host+. If +settings+ is given, the options are merged into
      # that hash, with existing values taking precedence over newly parsed
      # ones. Returns a hash containing the OpenSSH options. (See
      # #translate for how to convert the OpenSSH options into Net::SSH
      # options.)
      def load(path, host, settings={}, base_dir = nil)
        file = File.expand_path(path)
        base_dir ||= File.dirname(file)
        return settings unless File.readable?(file)

        globals = {}
        host_matched = false
        seen_host = false
        IO.foreach(file) do |line|
          next if line =~ /^\s*(?:#.*)?$/

          if line =~ /^\s*(\S+)\s*=(.*)$/
            key, value = $1, $2
          else
            key, value = line.strip.split(/\s+/, 2)
          end

          # silently ignore malformed entries
          next if value.nil?

          key.downcase!
          value = $1 if value =~ /^"(.*)"$/

          value = case value.strip
            when /^\d+$/ then value.to_i
            when /^no$/i then false
            when /^yes$/i then true
            else value
            end

          if key == 'host'
            # Support "Host host1 host2 hostN".
            # See http://github.com/net-ssh/net-ssh/issues#issue/6
            negative_hosts, positive_hosts = value.to_s.split(/\s+/).partition { |h| h.start_with?('!') }

            # Check for negative patterns first. If the host matches, that overrules any other positive match.
            # The host substring code is used to strip out the starting "!" so the regexp will be correct.
            negative_matched = negative_hosts.any? { |h| host =~ pattern2regex(h[1..-1]) }

            if negative_matched
              host_matched = false
            else
              host_matched = positive_hosts.any? { |h| host =~ pattern2regex(h) }
            end

            seen_host = true
            settings[key] = host
          elsif !seen_host
            case key
            when 'identityfile'
              (globals[key] ||= []) << value
            when 'include'
              included_file_paths(base_dir, value).each do |file_path|
                globals = load(file_path, host, globals, base_dir)
              end
            else
              globals[key] = value unless settings.key?(key)
            end
          elsif host_matched
            case key
            when 'identityfile'
              (settings[key] ||= []) << value
            when 'include'
              included_file_paths(base_dir, value).each do |file_path|
                settings = load(file_path, host, settings, base_dir)
              end
            else
              settings[key] = value unless settings.key?(key)
            end
          end
        end

        if globals
          settings = globals.merge(settings) do |key, oldval, newval|
            case key
            when 'identityfile'
              oldval + newval
            else
              newval
            end
          end
        end

        return settings
      end

      # Given a hash of OpenSSH configuration options, converts them into
      # a hash of Net::SSH options. Unrecognized options are ignored. The
      # +settings+ hash must have Strings for keys, all downcased, and
      # the returned hash will have Symbols for keys.
      def translate(settings)
        auth_methods = default_auth_methods.clone
        (auth_methods << 'challenge-response').uniq!
        ret = settings.inject({auth_methods: auth_methods}) do |hash, (key, value)|
          translate_config_key(hash, key.to_sym, value, settings)
          hash
        end
        merge_challenge_response_with_keyboard_interactive(ret)
      end

      # Filters default_files down to the files that are expandable.
      def expandable_default_files
        default_files.keep_if do |path|
          begin
            File.expand_path(path)
            true
          rescue ArgumentError
            false
          end
        end
      end

      private

        def translate_config_key(hash, key, value, settings)
          rename = {
            bindaddress: :bind_address,
            compression: :compression,
            compressionlevel: :compression_level,
            connecttimeout: :timeout,
            forwardagent: :forward_agent,
            identitiesonly: :keys_only,
            globalknownhostsfile: :global_known_hosts_file,
            hostkeyalias: :host_key_alias,
            identityfile: :keys,
            port: :port,
            user: :user,
            userknownhostsfile: :user_known_hosts_file
          }
          case key
            when :ciphers
              hash[:encryption] = value.split(/,/)
            when :hostbasedauthentication
              if value
                (hash[:auth_methods] << "hostbased").uniq!
              else
                hash[:auth_methods].delete("hostbased")
              end
            when :hostkeyalgorithms
              hash[:host_key] = value.split(/,/)
            when :hostname
              hash[:host_name] = value.gsub(/%h/, settings['host'])
            when :macs
              hash[:hmac] = value.split(/,/)
            when :serveralivecountmax
              hash[:keepalive_maxcount] = value.to_i if value
            when :serveraliveinterval
              if value && value.to_i > 0
                hash[:keepalive] = true
                hash[:keepalive_interval] = value.to_i
              else
                hash[:keepalive] = false
              end
            when :passwordauthentication
              if value
                (hash[:auth_methods] << 'password').uniq!
              else
                hash[:auth_methods].delete('password')
              end
            when :challengeresponseauthentication
              if value
                (hash[:auth_methods] << 'challenge-response').uniq!
              else
                hash[:auth_methods].delete('challenge-response')
              end
            when :kbdinteractiveauthentication
              if value
                (hash[:auth_methods] << 'keyboard-interactive').uniq!
              else
                hash[:auth_methods].delete('keyboard-interactive')
              end
            when :preferredauthentications
              hash[:auth_methods] = value.split(/,/) # TODO we should place to preferred_auth_methods rather than auth_methods
            when :proxycommand
              if value and !(value =~ /^none$/)
                require 'net/ssh/proxy/command'
                hash[:proxy] = Net::SSH::Proxy::Command.new(value)
              end
            when :proxyjump
              if value
                require 'net/ssh/proxy/jump'
                hash[:proxy] = Net::SSH::Proxy::Jump.new(value)
              end
            when :pubkeyauthentication
              if value
                (hash[:auth_methods] << 'publickey').uniq!
              else
                hash[:auth_methods].delete('publickey')
              end
            when :rekeylimit
              hash[:rekey_limit] = interpret_size(value)
            when :sendenv
              multi_send_env = value.to_s.split(/\s+/)
              hash[:send_env] = multi_send_env.map { |e| Regexp.new pattern2regex(e).source, false }
            when :numberofpasswordprompts
              hash[:number_of_password_prompts] = value.to_i
            when *rename.keys
              hash[rename[key]] = value
          end
        end

        # Converts an ssh_config pattern into a regex for matching against
        # host names.
        def pattern2regex(pattern)
          tail = pattern
          prefix = ""
          while !tail.empty? do
            head,sep,tail = tail.partition(/[\*\?]/)
            prefix = prefix + Regexp.quote(head)
            case sep
            when '*'
              prefix += '.*'
            when '?'
              prefix += '.'
            when ''
            else
              fail "Unpexpcted sep:#{sep}"
            end
          end
          Regexp.new("^" + prefix + "$", true)
        end

        # Converts the given size into an integer number of bytes.
        def interpret_size(size)
          case size
          when /k$/i then size.to_i * 1024
          when /m$/i then size.to_i * 1024 * 1024
          when /g$/i then size.to_i * 1024 * 1024 * 1024
          else size.to_i
          end
        end

        def merge_challenge_response_with_keyboard_interactive(hash)
          if hash[:auth_methods].include?('challenge-response')
            hash[:auth_methods].delete('challenge-response')
            (hash[:auth_methods] << 'keyboard-interactive').uniq!
          end
          hash
        end

        def included_file_paths(base_dir, config_paths)
          tokenize_config_value(config_paths).flat_map do |path|
            Dir.glob(File.expand_path(path, base_dir)).select { |f| File.file?(f) }
          end
        end

        # Tokenize string into tokens.
        # A token is a word or a quoted sequence of words, separated by whitespaces.
        def tokenize_config_value(str)
          str.scan(/([^"\s]+)?(?:"([^"]+)")?\s*/).map(&:join)
        end
    end
  end

end; end
