# frozen_string_literal: true

#--
# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others.
# All rights reserved.
# See LICENSE.txt for permissions.
#++

require_relative "user_interaction"
require "rbconfig"

##
# Gem::ConfigFile RubyGems options and gem command options from gemrc.
#
# gemrc is a YAML file that uses strings to match gem command arguments and
# symbols to match RubyGems options.
#
# Gem command arguments use a String key that matches the command name and
# allow you to specify default arguments:
#
#   install: --no-rdoc --no-ri
#   update: --no-rdoc --no-ri
#
# You can use <tt>gem:</tt> to set default arguments for all commands.
#
# RubyGems options use symbol keys.  Valid options are:
#
# +:backtrace+:: See #backtrace
# +:sources+:: Sets Gem::sources
# +:verbose+:: See #verbose
# +:concurrent_downloads+:: See #concurrent_downloads
#
# gemrc files may exist in various locations and are read and merged in
# the following order:
#
# - system wide (/etc/gemrc)
# - per user (~/.gemrc)
# - per environment (gemrc files listed in the GEMRC environment variable)

class Gem::ConfigFile
  include Gem::UserInteraction

  DEFAULT_BACKTRACE = true
  DEFAULT_BULK_THRESHOLD = 1000
  DEFAULT_VERBOSITY = true
  DEFAULT_UPDATE_SOURCES = true
  DEFAULT_CONCURRENT_DOWNLOADS = 8
  DEFAULT_CERT_EXPIRATION_LENGTH_DAYS = 365
  DEFAULT_IPV4_FALLBACK_ENABLED = false
  # TODO: Use false as default value for this option in RubyGems 4.0
  DEFAULT_INSTALL_EXTENSION_IN_LIB = true

  ##
  # For Ruby packagers to set configuration defaults.  Set in
  # rubygems/defaults/operating_system.rb

  OPERATING_SYSTEM_DEFAULTS = Gem.operating_system_defaults

  ##
  # For Ruby implementers to set configuration defaults.  Set in
  # rubygems/defaults/#{RUBY_ENGINE}.rb

  PLATFORM_DEFAULTS = Gem.platform_defaults

  # :stopdoc:

  SYSTEM_CONFIG_PATH =
    begin
      require "etc"
      Etc.sysconfdir
    rescue LoadError, NoMethodError
      RbConfig::CONFIG["sysconfdir"] || "/etc"
    end

  # :startdoc:

  SYSTEM_WIDE_CONFIG_FILE = File.join SYSTEM_CONFIG_PATH, "gemrc"

  ##
  # List of arguments supplied to the config file object.

  attr_reader :args

  ##
  # Where to look for gems (deprecated)

  attr_accessor :path

  ##
  # Where to install gems (deprecated)

  attr_accessor :home

  ##
  # True if we print backtraces on errors.

  attr_writer :backtrace

  ##
  # Bulk threshold value.  If the number of missing gems are above this
  # threshold value, then a bulk download technique is used.  (deprecated)

  attr_accessor :bulk_threshold

  ##
  # Verbose level of output:
  # * false -- No output
  # * true -- Normal output
  # * :loud -- Extra output

  attr_accessor :verbose

  ##
  # Number of gem downloads that should be performed concurrently.

  attr_accessor :concurrent_downloads

  ##
  # True if we want to update the SourceInfoCache every time, false otherwise

  attr_accessor :update_sources

  ##
  # True if we want to force specification of gem server when pushing a gem

  attr_accessor :disable_default_gem_server

  # openssl verify mode value, used for remote https connection

  attr_reader :ssl_verify_mode

  ##
  # Path name of directory or file of openssl CA certificate, used for remote
  # https connection

  attr_accessor :ssl_ca_cert

  ##
  # sources to look for gems
  attr_accessor :sources

  ##
  # Expiration length to sign a certificate

  attr_accessor :cert_expiration_length_days

  ##
  # Install extensions into lib as well as into the extension directory.

  attr_accessor :install_extension_in_lib

  ##
  # == Experimental ==
  # Fallback to IPv4 when IPv6 is not reachable or slow (default: false)

  attr_accessor :ipv4_fallback_enabled

  ##
  # Path name of directory or file of openssl client certificate, used for remote https connection with client authentication

  attr_reader :ssl_client_cert

  ##
  # Create the config file object.  +args+ is the list of arguments
  # from the command line.
  #
  # The following command line options are handled early here rather
  # than later at the time most command options are processed.
  #
  # <tt>--config-file</tt>, <tt>--config-file==NAME</tt>::
  #   Obviously these need to be handled by the ConfigFile object to ensure we
  #   get the right config file.
  #
  # <tt>--backtrace</tt>::
  #   Backtrace needs to be turned on early so that errors before normal
  #   option parsing can be properly handled.
  #
  # <tt>--debug</tt>::
  #   Enable Ruby level debug messages.  Handled early for the same reason as
  #   --backtrace.
  #--
  # TODO: parse options upstream, pass in options directly

  def initialize(args)
    set_config_file_name(args)

    @backtrace = DEFAULT_BACKTRACE
    @bulk_threshold = DEFAULT_BULK_THRESHOLD
    @verbose = DEFAULT_VERBOSITY
    @update_sources = DEFAULT_UPDATE_SOURCES
    @concurrent_downloads = DEFAULT_CONCURRENT_DOWNLOADS
    @cert_expiration_length_days = DEFAULT_CERT_EXPIRATION_LENGTH_DAYS
    @install_extension_in_lib = DEFAULT_INSTALL_EXTENSION_IN_LIB
    @ipv4_fallback_enabled = ENV["IPV4_FALLBACK_ENABLED"] == "true" || DEFAULT_IPV4_FALLBACK_ENABLED

    operating_system_config = Marshal.load Marshal.dump(OPERATING_SYSTEM_DEFAULTS)
    platform_config = Marshal.load Marshal.dump(PLATFORM_DEFAULTS)
    system_config = load_file SYSTEM_WIDE_CONFIG_FILE
    user_config = load_file config_file_name

    environment_config = (ENV["GEMRC"] || "").
      split(File::PATH_SEPARATOR).inject({}) do |result, file|
        result.merge load_file file
      end

    @hash = operating_system_config.merge platform_config
    unless args.index "--norc"
      @hash = @hash.merge system_config
      @hash = @hash.merge user_config
      @hash = @hash.merge environment_config
    end

    @hash.transform_keys! do |k|
      # gemhome and gempath are not working with symbol keys
      if %w[backtrace bulk_threshold verbose update_sources cert_expiration_length_days
            install_extension_in_lib ipv4_fallback_enabled sources disable_default_gem_server
            ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k)
        k.to_sym
      else
        k
      end
    end

    # HACK: these override command-line args, which is bad
    @backtrace                   = @hash[:backtrace]                   if @hash.key? :backtrace
    @bulk_threshold              = @hash[:bulk_threshold]              if @hash.key? :bulk_threshold
    @verbose                     = @hash[:verbose]                     if @hash.key? :verbose
    @update_sources              = @hash[:update_sources]              if @hash.key? :update_sources
    # TODO: We should handle concurrent_downloads same as other options
    @cert_expiration_length_days = @hash[:cert_expiration_length_days] if @hash.key? :cert_expiration_length_days
    @install_extension_in_lib    = @hash[:install_extension_in_lib]    if @hash.key? :install_extension_in_lib
    @ipv4_fallback_enabled       = @hash[:ipv4_fallback_enabled]       if @hash.key? :ipv4_fallback_enabled

    @home                        = @hash[:gemhome]                     if @hash.key? :gemhome
    @path                        = @hash[:gempath]                     if @hash.key? :gempath
    @sources                     = @hash[:sources]                     if @hash.key? :sources
    @disable_default_gem_server  = @hash[:disable_default_gem_server]  if @hash.key? :disable_default_gem_server
    @ssl_verify_mode             = @hash[:ssl_verify_mode]             if @hash.key? :ssl_verify_mode
    @ssl_ca_cert                 = @hash[:ssl_ca_cert]                 if @hash.key? :ssl_ca_cert
    @ssl_client_cert             = @hash[:ssl_client_cert]             if @hash.key? :ssl_client_cert

    @api_keys         = nil
    @rubygems_api_key = nil

    handle_arguments args
  end

  ##
  # Hash of RubyGems.org and alternate API keys

  def api_keys
    load_api_keys unless @api_keys

    @api_keys
  end

  ##
  # Checks the permissions of the credentials file.  If they are not 0600 an
  # error message is displayed and RubyGems aborts.

  def check_credentials_permissions
    return if Gem.win_platform? # windows doesn't write 0600 as 0600
    return unless File.exist? credentials_path

    existing_permissions = File.stat(credentials_path).mode & 0o777

    return if existing_permissions == 0o600

    alert_error <<-ERROR
Your gem push credentials file located at:

\t#{credentials_path}

has file permissions of 0#{existing_permissions.to_s 8} but 0600 is required.

To fix this error run:

\tchmod 0600 #{credentials_path}

You should reset your credentials at:

\thttps://rubygems.org/profile/edit

if you believe they were disclosed to a third party.
    ERROR

    terminate_interaction 1
  end

  ##
  # Location of RubyGems.org credentials

  def credentials_path
    credentials = File.join Gem.user_home, ".gem", "credentials"
    if File.exist? credentials
      credentials
    else
      File.join Gem.data_home, "gem", "credentials"
    end
  end

  def load_api_keys
    check_credentials_permissions

    @api_keys = if File.exist? credentials_path
      load_file(credentials_path)
    else
      @hash
    end

    if @api_keys.key? :rubygems_api_key
      @rubygems_api_key    = @api_keys[:rubygems_api_key]
      @api_keys[:rubygems] = @api_keys.delete :rubygems_api_key unless
        @api_keys.key? :rubygems
    end
  end

  ##
  # Returns the RubyGems.org API key

  def rubygems_api_key
    load_api_keys unless @rubygems_api_key

    @rubygems_api_key
  end

  ##
  # Sets the RubyGems.org API key to +api_key+

  def rubygems_api_key=(api_key)
    set_api_key :rubygems_api_key, api_key

    @rubygems_api_key = api_key
  end

  ##
  # Set a specific host's API key to +api_key+

  def set_api_key(host, api_key)
    check_credentials_permissions

    config = load_file(credentials_path).merge(host => api_key)

    dirname = File.dirname credentials_path
    require "fileutils"
    FileUtils.mkdir_p(dirname)

    permissions = 0o600 & ~File.umask
    File.open(credentials_path, "w", permissions) do |f|
      f.write self.class.dump_with_rubygems_yaml(config)
    end

    load_api_keys # reload
  end

  ##
  # Remove the +~/.gem/credentials+ file to clear all the current sessions.

  def unset_api_key!
    return false unless File.exist?(credentials_path)

    File.delete(credentials_path)
  end

  def load_file(filename)
    yaml_errors = [ArgumentError]

    return {} unless filename && !filename.empty? && File.exist?(filename)

    begin
      config = self.class.load_with_rubygems_config_hash(File.read(filename))
      if config.keys.any? {|k| k.to_s.gsub(%r{https?:\/\/}, "").include?(": ") }
        warn "Failed to load #{filename} because it doesn't contain valid YAML hash"
        return {}
      else
        return config
      end
    rescue *yaml_errors => e
      warn "Failed to load #{filename}, #{e}"
    rescue Errno::EACCES
      warn "Failed to load #{filename} due to permissions problem."
    end

    {}
  end

  # True if the backtrace option has been specified, or debug is on.
  def backtrace
    @backtrace || $DEBUG
  end

  # Check state file is writable. Creates empty file if not present to ensure we can write to it.
  def state_file_writable?
    if File.exist?(state_file_name)
      File.writable?(state_file_name)
    else
      require "fileutils"
      FileUtils.mkdir_p File.dirname(state_file_name)
      File.open(state_file_name, "w") {}
      true
    end
  rescue Errno::EACCES
    false
  end

  # The name of the configuration file.
  def config_file_name
    @config_file_name || Gem.config_file
  end

  # The name of the state file.
  def state_file_name
    Gem.state_file
  end

  # Reads time of last update check from state file
  def last_update_check
    if File.readable?(state_file_name)
      File.read(state_file_name).to_i
    else
      0
    end
  end

  # Writes time of last update check to state file
  def last_update_check=(timestamp)
    File.write(state_file_name, timestamp.to_s) if state_file_writable?
  end

  # Delegates to @hash
  def each(&block)
    hash = @hash.dup
    hash.delete :update_sources
    hash.delete :verbose
    hash.delete :backtrace
    hash.delete :bulk_threshold

    yield :update_sources, @update_sources
    yield :verbose, @verbose
    yield :backtrace, @backtrace
    yield :bulk_threshold, @bulk_threshold

    yield "config_file_name", @config_file_name if @config_file_name

    hash.each(&block)
  end

  # Handle the command arguments.
  def handle_arguments(arg_list)
    @args = []

    arg_list.each do |arg|
      case arg
      when /^--(backtrace|traceback)$/ then
        @backtrace = true
      when /^--debug$/ then
        $DEBUG = true

        warn "NOTE:  Debugging mode prints all exceptions even when rescued"
      else
        @args << arg
      end
    end
  end

  # Really verbose mode gives you extra output.
  def really_verbose
    case verbose
    when true, false, nil then
      false
    else
      true
    end
  end

  # to_yaml only overwrites things you can't override on the command line.
  def to_yaml # :nodoc:
    yaml_hash = {}
    yaml_hash[:backtrace] = @hash.fetch(:backtrace, DEFAULT_BACKTRACE)
    yaml_hash[:bulk_threshold] = @hash.fetch(:bulk_threshold, DEFAULT_BULK_THRESHOLD)
    yaml_hash[:sources] = Gem.sources.to_a
    yaml_hash[:update_sources] = @hash.fetch(:update_sources, DEFAULT_UPDATE_SOURCES)
    yaml_hash[:verbose] = @hash.fetch(:verbose, DEFAULT_VERBOSITY)

    yaml_hash[:concurrent_downloads] =
      @hash.fetch(:concurrent_downloads, DEFAULT_CONCURRENT_DOWNLOADS)

    yaml_hash[:install_extension_in_lib] =
      @hash.fetch(:install_extension_in_lib, DEFAULT_INSTALL_EXTENSION_IN_LIB)

    yaml_hash[:ssl_verify_mode] =
      @hash[:ssl_verify_mode] if @hash.key? :ssl_verify_mode

    yaml_hash[:ssl_ca_cert] =
      @hash[:ssl_ca_cert] if @hash.key? :ssl_ca_cert

    yaml_hash[:ssl_client_cert] =
      @hash[:ssl_client_cert] if @hash.key? :ssl_client_cert

    keys = yaml_hash.keys.map(&:to_s)
    keys << "debug"
    re = Regexp.union(*keys)

    @hash.each do |key, value|
      key = key.to_s
      next if key&.match?(re)
      yaml_hash[key.to_s] = value
    end

    self.class.dump_with_rubygems_yaml(yaml_hash)
  end

  # Writes out this config file, replacing its source.
  def write
    require "fileutils"
    FileUtils.mkdir_p File.dirname(config_file_name)

    File.open config_file_name, "w" do |io|
      io.write to_yaml
    end
  end

  # Return the configuration information for +key+.
  def [](key)
    @hash[key] || @hash[key.to_s]
  end

  # Set configuration option +key+ to +value+.
  def []=(key, value)
    @hash[key] = value
  end

  def ==(other) # :nodoc:
    self.class === other &&
      @backtrace == other.backtrace &&
      @bulk_threshold == other.bulk_threshold &&
      @verbose == other.verbose &&
      @update_sources == other.update_sources &&
      @hash == other.hash
  end

  attr_reader :hash
  protected :hash

  def self.dump_with_rubygems_yaml(content)
    content.transform_keys! do |k|
      k.is_a?(Symbol) ? ":#{k}" : k
    end

    require_relative "yaml_serializer"
    Gem::YAMLSerializer.dump(content)
  end

  def self.load_with_rubygems_config_hash(yaml)
    require_relative "yaml_serializer"

    content = Gem::YAMLSerializer.load(yaml)
    deep_transform_config_keys!(content)
  end

  private

  def self.deep_transform_config_keys!(config)
    config.transform_keys! do |k|
      if k.match?(/\A:(.*)\Z/)
        k[1..-1].to_sym
      elsif k.include?("__") || k.match?(%r{/\Z})
        if k.is_a?(Symbol)
          k.to_s.gsub(/__/,".").gsub(%r{/\Z}, "").to_sym
        else
          k.dup.gsub(/__/,".").gsub(%r{/\Z}, "")
        end
      else
        k
      end
    end

    config.transform_values! do |v|
      if v.is_a?(String)
        if v.match?(/\A:(.*)\Z/)
          v[1..-1].to_sym
        elsif v.match?(/\A[+-]?\d+\Z/)
          v.to_i
        elsif v.match?(/\Atrue|false\Z/)
          v == "true"
        elsif v.empty?
          nil
        else
          v
        end
      elsif v.empty?
        nil
      elsif v.is_a?(Hash)
        deep_transform_config_keys!(v)
      else
        v
      end
    end

    config
  end

  def set_config_file_name(args)
    @config_file_name = ENV["GEMRC"]
    need_config_file_name = false

    args.each do |arg|
      if need_config_file_name
        @config_file_name = arg
        need_config_file_name = false
      elsif arg =~ /^--config-file=(.*)/
        @config_file_name = $1
      elsif /^--config-file$/.match?(arg)
        need_config_file_name = true
      end
    end
  end
end
