diff options
Diffstat (limited to 'lib/rubygems')
238 files changed, 54705 insertions, 0 deletions
diff --git a/lib/rubygems/available_set.rb b/lib/rubygems/available_set.rb new file mode 100644 index 0000000000..0af80cc3db --- /dev/null +++ b/lib/rubygems/available_set.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +class Gem::AvailableSet + include Enumerable + + Tuple = Struct.new(:spec, :source) + + attr_accessor :remote # :nodoc: + + def initialize + @set = [] + @sorted = nil + @remote = true + end + + attr_reader :set + + def add(spec, source) + @set << Tuple.new(spec, source) + @sorted = nil + self + end + + def <<(o) + case o + when Gem::AvailableSet + s = o.set + when Array + s = o.map do |sp,so| + if !sp.is_a?(Gem::Specification) || !so.is_a?(Gem::Source) + raise TypeError, "Array must be in [[spec, source], ...] form" + end + + Tuple.new(sp,so) + end + else + raise TypeError, "must be a Gem::AvailableSet" + end + + @set += s + @sorted = nil + + self + end + + ## + # Yields each Tuple in this AvailableSet + + def each + return enum_for __method__ unless block_given? + + @set.each do |tuple| + yield tuple + end + end + + ## + # Yields the Gem::Specification for each Tuple in this AvailableSet + + def each_spec + return enum_for __method__ unless block_given? + + each do |tuple| + yield tuple.spec + end + end + + def empty? + @set.empty? + end + + def all_specs + @set.map(&:spec) + end + + def match_platform! + @set.reject! {|t| !Gem::Platform.match_spec?(t.spec) } + @sorted = nil + self + end + + def sorted + @sorted ||= @set.sort do |a,b| + i = b.spec <=> a.spec + i != 0 ? i : (a.source <=> b.source) + end + end + + def size + @set.size + end + + def source_for(spec) + f = @set.find {|t| t.spec == spec } + f.source + end + + ## + # Converts this AvailableSet into a RequestSet that can be used to install + # gems. + # + # If +development+ is :none then no development dependencies are installed. + # Other options are :shallow for only direct development dependencies of the + # gems in this set or :all for all development dependencies. + + def to_request_set(development = :none) + request_set = Gem::RequestSet.new + request_set.development = development == :all + + each_spec do |spec| + request_set.always_install << spec + + request_set.gem spec.name, spec.version + request_set.import spec.development_dependencies if + development == :shallow + end + + request_set + end + + ## + # + # Used by the Resolver, the protocol to use a AvailableSet as a + # search Set. + + def find_all(req) + dep = req.dependency + + match = @set.find_all do |t| + dep.match? t.spec + end + + match.map do |t| + Gem::Resolver::LocalSpecification.new(self, t.spec, t.source) + end + end + + def prefetch(reqs) + end + + def pick_best! + return self if empty? + + @set = [sorted.first] + @sorted = nil + self + end + + def remove_installed!(dep) + @set.reject! do |_t| + # already locally installed + Gem::Specification.any? do |installed_spec| + dep.name == installed_spec.name && + dep.requirement.satisfied_by?(installed_spec.version) + end + end + + @sorted = nil + self + end + + def inject_into_list(dep_list) + @set.each {|t| dep_list.add t.spec } + end +end diff --git a/lib/rubygems/basic_specification.rb b/lib/rubygems/basic_specification.rb new file mode 100644 index 0000000000..0ed7fc60bb --- /dev/null +++ b/lib/rubygems/basic_specification.rb @@ -0,0 +1,384 @@ +# frozen_string_literal: true + +## +# BasicSpecification is an abstract class which implements some common code +# used by both Specification and StubSpecification. + +class Gem::BasicSpecification + ## + # Allows installation of extensions for git: gems. + + attr_writer :base_dir # :nodoc: + + ## + # Sets the directory where extensions for this gem will be installed. + + attr_writer :extension_dir # :nodoc: + + ## + # Is this specification ignored for activation purposes? + + attr_writer :ignored # :nodoc: + + ## + # The path this gemspec was loaded from. This attribute is not persisted. + + attr_accessor :loaded_from + + ## + # Allows correct activation of git: and path: gems. + + attr_writer :full_gem_path # :nodoc: + + def initialize + internal_init + end + + ## + # The path to the gem.build_complete file within the extension install + # directory. + + def gem_build_complete_path # :nodoc: + File.join extension_dir, "gem.build_complete" + end + + ## + # True when the gem has been activated + + def activated? + raise NotImplementedError + end + + ## + # Returns the full path to the base gem directory. + # + # eg: /usr/local/lib/ruby/gems/1.8 + + def base_dir + raise NotImplementedError + end + + ## + # Return true if this spec can require +file+. + + def contains_requirable_file?(file) + if ignored? + if platform == Gem::Platform::RUBY || Gem::Platform.local === platform + warn "Ignoring #{full_name} because its extensions are not built. " \ + "Try: gem pristine #{name} --version #{version}" + end + + return false + end + + is_soext = file.end_with?(".so", ".o") + + if is_soext + have_file? file.delete_suffix(File.extname(file)), Gem.dynamic_library_suffixes + else + have_file? file, Gem.suffixes + end + end + + ## + # Return true if this spec should be ignored because it's missing extensions. + + def ignored? + return @ignored unless @ignored.nil? + + @ignored = missing_extensions? + end + + def default_gem? + !loaded_from.nil? && + File.dirname(loaded_from) == Gem.default_specifications_dir + end + + ## + # Regular gems take precedence over default gems + + def default_gem_priority + default_gem? ? 1 : -1 + end + + ## + # Gems higher up in +gem_path+ take precedence + + def base_dir_priority(gem_path) + gem_path.index(base_dir) || gem_path.size + end + + ## + # Returns full path to the directory where gem's extensions are installed. + + def extension_dir + @extension_dir ||= File.expand_path(File.join(extensions_dir, full_name)) + end + + ## + # Returns path to the extensions directory. + + def extensions_dir + Gem.default_ext_dir_for(base_dir) || + File.join(base_dir, "extensions", Gem::Platform.local.to_s, + Gem.extension_api_version) + end + + def find_full_gem_path # :nodoc: + File.expand_path File.join(gems_dir, full_name) + end + + private :find_full_gem_path + + ## + # The full path to the gem (install path + full name). + # + # TODO: This is duplicated with #gem_dir. Eventually either of them should be deprecated. + + def full_gem_path + @full_gem_path ||= find_full_gem_path + end + + ## + # Returns the full name (name-version) of this Gem. Platform information + # is included (name-version-platform) if it is specified and not the + # default Ruby platform. + + def full_name + if platform == Gem::Platform::RUBY || platform.nil? + "#{name}-#{version}" + else + "#{name}-#{version}-#{platform}" + end + end + + ## + # Returns the full name of this Gem (see `Gem::BasicSpecification#full_name`). + # Information about where the gem is installed is also included if not + # installed in the default GEM_HOME. + + def full_name_with_location + if base_dir != Gem.dir + "#{full_name} in #{base_dir}" + else + full_name + end + end + + ## + # Full paths in the gem to add to <code>$LOAD_PATH</code> when this gem is + # activated. + + def full_require_paths + @full_require_paths ||= + begin + full_paths = raw_require_paths.map do |path| + File.join full_gem_path, path + end + + full_paths << extension_dir if have_extensions? + + full_paths + end + end + + ## + # The path to the data directory for this gem. + + def datadir + # TODO: drop the extra ", gem_name" which is uselessly redundant + File.expand_path(File.join(gems_dir, full_name, "data", name)) + end + + extend Gem::Deprecate + rubygems_deprecate :datadir, :none, "4.1" + + ## + # Full path of the target library file. + # If the file is not in this gem, return nil. + + def to_fullpath(path) + if activated? + @paths_map ||= {} + Gem.suffixes.each do |suf| + full_require_paths.each do |dir| + fullpath = "#{dir}/#{path}#{suf}" + next unless File.file?(fullpath) + @paths_map[path] ||= fullpath + end + end + @paths_map[path] + end + end + + ## + # Returns the full path to this spec's gem directory. + # eg: /usr/local/lib/ruby/1.8/gems/mygem-1.0 + # + # TODO: This is duplicated with #full_gem_path. Eventually either of them should be deprecated. + + def gem_dir + @gem_dir ||= find_full_gem_path + end + + ## + # Returns the full path to the gems directory containing this spec's + # gem directory. eg: /usr/local/lib/ruby/1.8/gems + + def gems_dir + raise NotImplementedError + end + + def internal_init # :nodoc: + @extension_dir = nil + @full_gem_path = nil + @gem_dir = nil + @ignored = nil + end + + ## + # Name of the gem + + def name + raise NotImplementedError + end + + ## + # Platform of the gem + + def platform + raise NotImplementedError + end + + def installable_on_platform?(target_platform) # :nodoc: + return true if [Gem::Platform::RUBY, nil, target_platform].include?(platform) + return true if Gem::Platform.new(platform) === target_platform + + false + end + + def raw_require_paths # :nodoc: + raise NotImplementedError + end + + ## + # Paths in the gem to add to <code>$LOAD_PATH</code> when this gem is + # activated. + # + # See also #require_paths= + # + # If you have an extension you do not need to add <code>"ext"</code> to the + # require path, the extension build process will copy the extension files + # into "lib" for you. + # + # The default value is <code>"lib"</code> + # + # Usage: + # + # # If all library files are in the root directory... + # spec.require_path = '.' + + def require_paths + return raw_require_paths unless have_extensions? + + [extension_dir].concat raw_require_paths + end + + ## + # Returns the paths to the source files for use with analysis and + # documentation tools. These paths are relative to full_gem_path. + + def source_paths + paths = raw_require_paths.dup + + if have_extensions? + ext_dirs = extensions.map do |extension| + extension.split(File::SEPARATOR, 2).first + end.uniq + + paths.concat ext_dirs + end + + paths.uniq + end + + ## + # Return all files in this gem that match for +glob+. + + def matches_for_glob(glob) # TODO: rename? + glob = File.join(lib_dirs_glob, glob) + + Dir[glob] + end + + ## + # Returns the list of plugins in this spec. + + def plugins + matches_for_glob("rubygems#{Gem.plugin_suffix_pattern}") + end + + ## + # Returns a string usable in Dir.glob to match all requirable paths + # for this spec. + + def lib_dirs_glob + dirs = if raw_require_paths + if raw_require_paths.size > 1 + "{#{raw_require_paths.join(",")}}" + else + raw_require_paths.first + end + else + "lib" # default value for require_paths for bundler/inline + end + + "#{full_gem_path}/#{dirs}" + end + + ## + # Return a Gem::Specification from this gem + + def to_spec + raise NotImplementedError + end + + ## + # Version of the gem + + def version + raise NotImplementedError + end + + ## + # Whether this specification is stubbed - i.e. we have information + # about the gem from a stub line, without having to evaluate the + # entire gemspec file. + def stubbed? + raise NotImplementedError + end + + def this + self + end + + private + + def have_extensions? + !extensions.empty? + end + + def have_file?(file, suffixes) + return true if raw_require_paths.any? do |path| + base = File.join(gems_dir, full_name, path, file) + suffixes.any? {|suf| File.file? base + suf } + end + + if have_extensions? + base = File.join extension_dir, file + suffixes.any? {|suf| File.file? base + suf } + else + false + end + end +end diff --git a/lib/rubygems/bundler_version_finder.rb b/lib/rubygems/bundler_version_finder.rb new file mode 100644 index 0000000000..bbe7bf0ab5 --- /dev/null +++ b/lib/rubygems/bundler_version_finder.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Gem::BundlerVersionFinder + def self.bundler_version + bcv = bundle_config_version + return if bcv == "system" + + v = ENV["BUNDLER_VERSION"] + v = nil if v&.empty? + + v ||= bundle_update_bundler_version + return if v == true + + v ||= bcv unless bcv == "lockfile" + + v ||= lockfile_version + return unless v + + Gem::Version.new(v) + end + + def self.prioritize!(specs) + exact_match_index = specs.find_index {|spec| spec.version == bundler_version } + return unless exact_match_index + + specs.unshift(specs.delete_at(exact_match_index)) + end + + def self.bundle_update_bundler_version + return unless ["bundle", "bundler"].include? File.basename($0) + return unless "update".start_with?(ARGV.first || " ") + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 || true + update_index = i + end + bundler_version + end + private_class_method :bundle_update_bundler_version + + def self.lockfile_version + return unless contents = lockfile_contents + regexp = /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + return unless contents =~ regexp + $1 + end + private_class_method :lockfile_version + + def self.lockfile_contents + gemfile = gemfile_path + + return unless gemfile + + lockfile = ENV["BUNDLE_LOCKFILE"] + lockfile = nil if lockfile&.empty? + + lockfile ||= case gemfile + when "gems.rb" then "gems.locked" + else "#{gemfile}.lock" + end + + return unless File.file?(lockfile) + + File.read(lockfile) + end + private_class_method :lockfile_contents + + def self.bundle_config_version + env_version = ENV["BUNDLE_VERSION"] + return env_version if env_version && !env_version.empty? + + version = nil + + [bundler_local_config_file, bundler_global_config_file].each do |config_file| + next unless config_file && File.file?(config_file) + + contents = File.read(config_file) + contents =~ /^BUNDLE_VERSION:\s*["']?([^"'\s]+)["']?\s*$/ + + version = $1 + break if version + end + + version + end + private_class_method :bundle_config_version + + def self.bundler_global_config_file + # see Bundler::Settings#global_config_file + if ENV["BUNDLE_CONFIG"] && !ENV["BUNDLE_CONFIG"].empty? + ENV["BUNDLE_CONFIG"] + elsif ENV["BUNDLE_USER_CONFIG"] && !ENV["BUNDLE_USER_CONFIG"].empty? + ENV["BUNDLE_USER_CONFIG"] + elsif ENV["BUNDLE_USER_HOME"] && !ENV["BUNDLE_USER_HOME"].empty? + ENV["BUNDLE_USER_HOME"] + "config" + elsif Gem.user_home && !Gem.user_home.empty? + Gem.user_home + ".bundle/config" + end + end + private_class_method :bundler_global_config_file + + def self.bundler_local_config_file + gemfile = gemfile_path + return unless gemfile + + File.join(File.dirname(gemfile), ".bundle", "config") + end + private_class_method :bundler_local_config_file + + def self.gemfile_path + gemfile = ENV["BUNDLE_GEMFILE"] + gemfile = nil if gemfile&.empty? + + unless gemfile + begin + Gem::Util.traverse_parents(Dir.pwd) do |directory| + next unless gemfile = Gem::GEM_DEP_FILES.find {|f| File.file?(f) } + + gemfile = File.join directory, gemfile + break + end + rescue Errno::ENOENT + return + end + end + + gemfile + end + private_class_method :gemfile_path +end diff --git a/lib/rubygems/ci_detector.rb b/lib/rubygems/ci_detector.rb new file mode 100644 index 0000000000..7a2d4ee29a --- /dev/null +++ b/lib/rubygems/ci_detector.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Gem + module CIDetector + # NOTE: Any changes made here will need to be made to both lib/rubygems/ci_detector.rb and + # bundler/lib/bundler/ci_detector.rb (which are enforced duplicates). + # TODO: Drop that duplication once bundler drops support for RubyGems 3.4 + # + # ## Recognized CI providers, their signifiers, and the relevant docs ## + # + # Travis CI - CI, TRAVIS https://docs.travis-ci.com/user/environment-variables/#default-environment-variables + # Cirrus CI - CI, CIRRUS_CI https://cirrus-ci.org/guide/writing-tasks/#environment-variables + # Circle CI - CI, CIRCLECI https://circleci.com/docs/variables/#built-in-environment-variables + # Gitlab CI - CI, GITLAB_CI https://docs.gitlab.com/ee/ci/variables/ + # AppVeyor - CI, APPVEYOR https://www.appveyor.com/docs/environment-variables/ + # CodeShip - CI_NAME https://docs.cloudbees.com/docs/cloudbees-codeship/latest/pro-builds-and-configuration/environment-variables#_default_environment_variables + # dsari - CI, DSARI https://github.com/rfinnie/dsari#running + # Jenkins - BUILD_NUMBER https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables + # TeamCity - TEAMCITY_VERSION https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters + # Appflow - CI_BUILD_ID https://ionic.io/docs/appflow/automation/environments#predefined-environments + # TaskCluster - TASKCLUSTER_ROOT_URL https://docs.taskcluster.net/docs/manual/design/env-vars + # Semaphore - CI, SEMAPHORE https://docs.semaphoreci.com/ci-cd-environment/environment-variables/ + # BuildKite - CI, BUILDKITE https://buildkite.com/docs/pipelines/environment-variables + # GoCD - GO_SERVER_URL https://docs.gocd.org/current/faq/dev_use_current_revision_in_build.html + # GH Actions - CI, GITHUB_ACTIONS https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + # + # ### Some "standard" ENVs that multiple providers may set ### + # + # * CI - this is set by _most_ (but not all) CI providers now; it's approaching a standard. + # * CI_NAME - Not as frequently used, but some providers set this to specify their own name + + # Any of these being set is a reasonably reliable indicator that we are + # executing in a CI environment. + ENV_INDICATORS = [ + "CI", + "CI_NAME", + "CONTINUOUS_INTEGRATION", + "BUILD_NUMBER", + "CI_APP_ID", + "CI_BUILD_ID", + "CI_BUILD_NUMBER", + "RUN_ID", + "TASKCLUSTER_ROOT_URL", + ].freeze + + # For each CI, this env suffices to indicate that we're on _that_ CI's + # containers. (A few of them only supply a CI_NAME variable, which is also + # nice). And if they set "CI" but we can't tell which one they are, we also + # want to know that - a bare "ci" without another token tells us as much. + ENV_DESCRIPTORS = { + "TRAVIS" => "travis", + "CIRCLECI" => "circle", + "CIRRUS_CI" => "cirrus", + "DSARI" => "dsari", + "SEMAPHORE" => "semaphore", + "JENKINS_URL" => "jenkins", + "BUILDKITE" => "buildkite", + "GO_SERVER_URL" => "go", + "GITLAB_CI" => "gitlab", + "GITHUB_ACTIONS" => "github", + "TASKCLUSTER_ROOT_URL" => "taskcluster", + "CI" => "ci", + }.freeze + + def self.ci? + ENV_INDICATORS.any? {|var| ENV.include?(var) } + end + + def self.ci_strings + matching_names = ENV_DESCRIPTORS.select {|env, _| ENV[env] }.values + matching_names << ENV["CI_NAME"].downcase if ENV["CI_NAME"] + matching_names.reject(&:empty?).sort.uniq + end + end +end diff --git a/lib/rubygems/command.rb b/lib/rubygems/command.rb new file mode 100644 index 0000000000..d38363f293 --- /dev/null +++ b/lib/rubygems/command.rb @@ -0,0 +1,664 @@ +# 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 "vendored_optparse" +require_relative "requirement" +require_relative "user_interaction" + +## +# Base class for all Gem commands. When creating a new gem command, define +# #initialize, #execute, #arguments, #defaults_str, #description and #usage +# (as appropriate). See the above mentioned methods for details. +# +# A very good example to look at is Gem::Commands::ContentsCommand + +class Gem::Command + include Gem::UserInteraction + + Gem::OptionParser.accept Symbol, &:to_sym + + ## + # The name of the command. + + attr_reader :command + + ## + # The options for the command. + + attr_reader :options + + ## + # The default options for the command. + + attr_accessor :defaults + + ## + # The name of the command for command-line invocation. + + attr_accessor :program_name + + ## + # A short description of the command. + + attr_accessor :summary + + ## + # Arguments used when building gems + + def self.build_args + @build_args ||= [] + end + + def self.build_args=(value) + @build_args = value + end + + def self.common_options + @common_options ||= [] + end + + def self.add_common_option(*args, &handler) + Gem::Command.common_options << [args, handler] + end + + def self.extra_args + @extra_args ||= [] + end + + def self.extra_args=(value) + case value + when Array + @extra_args = value + when String + @extra_args = value.split(" ") + end + end + + ## + # Return an array of extra arguments for the command. The extra arguments + # come from the gem configuration file read at program startup. + + def self.specific_extra_args(cmd) + specific_extra_args_hash[cmd] + end + + ## + # Add a list of extra arguments for the given command. +args+ may be an + # array or a string to be split on white space. + + def self.add_specific_extra_args(cmd,args) + args = args.split(/\s+/) if args.is_a? String + specific_extra_args_hash[cmd] = args + end + + ## + # Accessor for the specific extra args hash (self initializing). + + def self.specific_extra_args_hash + @specific_extra_args_hash ||= Hash.new do |h,k| + h[k] = Array.new + end + end + + ## + # Initializes a generic gem command named +command+. +summary+ is a short + # description displayed in `gem help commands`. +defaults+ are the default + # options. Defaults should be mirrored in #defaults_str, unless there are + # none. + # + # When defining a new command subclass, use add_option to add command-line + # switches. + # + # Unhandled arguments (gem names, files, etc.) are left in + # <tt>options[:args]</tt>. + + def initialize(command, summary = nil, defaults = {}) + @command = command + @summary = summary + @program_name = "gem #{command}" + @defaults = defaults + @options = defaults.dup + @option_groups = Hash.new {|h,k| h[k] = [] } + @deprecated_options = { command => {} } + @parser = nil + @when_invoked = nil + end + + ## + # True if +long+ begins with the characters from +short+. + + def begins?(long, short) + return false if short.nil? + long[0, short.length] == short + end + + ## + # Override to provide command handling. + # + # #options will be filled in with your parsed options, unparsed options will + # be left in <tt>options[:args]</tt>. + # + # See also: #get_all_gem_names, #get_one_gem_name, + # #get_one_optional_argument + + def execute + raise Gem::Exception, "generic command has no actions" + end + + ## + # Display to the user that a gem couldn't be found and reasons why + #-- + + def show_lookup_failure(gem_name, version, errors, suppress_suggestions = false, required_by = nil) + gem = "'#{gem_name}' (#{version})" + msg = String.new "Could not find a valid gem #{gem}" + + if errors && !errors.empty? + msg << ", here is why:\n" + errors.each {|x| msg << " #{x.wordy}\n" } + else + if required_by && gem != required_by + msg << " (required by #{required_by}) in any repository" + else + msg << " in any repository" + end + end + + alert_error msg + + unless suppress_suggestions + suggestions = Gem::SpecFetcher.fetcher.suggest_gems_from_name(gem_name, :latest, 10) + unless suggestions.empty? + alert_error "Possible alternatives: #{suggestions.join(", ")}" + end + end + end + + ## + # Get all gem names from the command line. + + def get_all_gem_names + args = options[:args] + + if args.nil? || args.empty? + raise Gem::CommandLineError, + "Please specify at least one gem name (e.g. gem build GEMNAME)" + end + + args.reject {|arg| arg.start_with?("-") } + end + + ## + # Get all [gem, version] from the command line. + # + # An argument in the form gem:ver is pull apart into the gen name and version, + # respectively. + def get_all_gem_names_and_versions + get_all_gem_names.map do |name| + extract_gem_name_and_version(name) + end + end + + def extract_gem_name_and_version(name) # :nodoc: + if /\A(.*):(#{Gem::Requirement::PATTERN_RAW})\z/ =~ name + [$1, $2] + else + [name] + end + end + + ## + # Get a single gem name from the command line. Fail if there is no gem name + # or if there is more than one gem name given. + + def get_one_gem_name + args = options[:args] + + if args.nil? || args.empty? + raise Gem::CommandLineError, + "Please specify a gem name on the command line (e.g. gem build GEMNAME)" + end + + if args.size > 1 + raise Gem::CommandLineError, + "Too many gem names (#{args.join(", ")}); please specify only one" + end + + args.first + end + + ## + # Get a single optional argument from the command line. If more than one + # argument is given, return only the first. Return nil if none are given. + + def get_one_optional_argument + args = options[:args] || [] + args.first + end + + ## + # Override to provide details of the arguments a command takes. It should + # return a left-justified string, one argument per line. + # + # For example: + # + # def usage + # "#{program_name} FILE [FILE ...]" + # end + # + # def arguments + # "FILE name of file to find" + # end + + def arguments + "" + end + + ## + # Override to display the default values of the command options. (similar to + # +arguments+, but displays the default values). + # + # For example: + # + # def defaults_str + # --no-gems-first --no-all + # end + + def defaults_str + "" + end + + ## + # Override to display a longer description of what this command does. + + def description + nil + end + + ## + # Override to display the usage for an individual gem command. + # + # The text "[options]" is automatically appended to the usage text. + + def usage + program_name + end + + ## + # Display the help message for the command. + + def show_help + parser.program_name = usage + say parser + end + + ## + # Invoke the command with the given list of arguments. + + def invoke(*args) + invoke_with_build_args args, nil + end + + ## + # Invoke the command with the given list of normal arguments + # and additional build arguments. + + def invoke_with_build_args(args, build_args) + handle_options args + + options[:build_args] = build_args + + if options[:silent] + old_ui = ui + self.ui = ui = Gem::SilentUI.new + end + + if options[:help] + show_help + elsif @when_invoked + @when_invoked.call options + else + execute + end + ensure + if ui + self.ui = old_ui + ui.close + end + end + + ## + # Call the given block when invoked. + # + # Normal command invocations just executes the +execute+ method of the + # command. Specifying an invocation block allows the test methods to + # override the normal action of a command to determine that it has been + # invoked correctly. + + def when_invoked(&block) + @when_invoked = block + end + + ## + # Add a command-line option and handler to the command. + # + # See Gem::OptionParser#make_switch for an explanation of +opts+. + # + # +handler+ will be called with two values, the value of the argument and + # the options hash. + # + # If the first argument of add_option is a Symbol, it's used to group + # options in output. See `gem help list` for an example. + + def add_option(*opts, &handler) # :yields: value, options + group_name = Symbol === opts.first ? opts.shift : :options + + raise "Do not pass an empty string in opts" if opts.include?("") + + @option_groups[group_name] << [opts, handler] + end + + ## + # Remove previously defined command-line argument +name+. + + def remove_option(name) + @option_groups.each do |_, option_list| + option_list.reject! {|args, _| args.any? {|x| x.is_a?(String) && x =~ /^#{name}/ } } + end + end + + ## + # Mark a command-line option as deprecated, and optionally specify a + # deprecation horizon. + # + # Note that with the current implementation, every version of the option needs + # to be explicitly deprecated, so to deprecate an option defined as + # + # add_option('-t', '--[no-]test', 'Set test mode') do |value, options| + # # ... stuff ... + # end + # + # you would need to explicitly add a call to `deprecate_option` for every + # version of the option you want to deprecate, like + # + # deprecate_option('-t') + # deprecate_option('--test') + # deprecate_option('--no-test') + + def deprecate_option(name, version: nil, extra_msg: nil) + @deprecated_options[command].merge!({ name => { "rg_version_to_expire" => version, "extra_msg" => extra_msg } }) + end + + def check_deprecated_options(options) + options.each do |option| + next unless option_is_deprecated?(option) + deprecation = @deprecated_options[command][option] + version_to_expire = deprecation["rg_version_to_expire"] + + deprecate_option_msg = if version_to_expire + "The \"#{option}\" option has been deprecated and will be removed in Rubygems #{version_to_expire}." + else + "The \"#{option}\" option has been deprecated and will be removed in future versions of Rubygems." + end + + extra_msg = deprecation["extra_msg"] + + deprecate_option_msg += " #{extra_msg}" if extra_msg + + alert_warning(deprecate_option_msg) + end + end + + ## + # Merge a set of command options with the set of default options (without + # modifying the default option hash). + + def merge_options(new_options) + @options = @defaults.clone + new_options.each {|k,v| @options[k] = v } + end + + ## + # True if the command handles the given argument list. + + def handles?(args) + parser.parse!(args.dup) + true + rescue StandardError + false + end + + ## + # Handle the given list of arguments by parsing them and recording the + # results. + + def handle_options(args) + args = add_extra_args(args) + check_deprecated_options(args) + @options = Marshal.load Marshal.dump @defaults # deep copy + parser.parse!(args) + @options[:args] = args + end + + ## + # Adds extra args from ~/.gemrc + + def add_extra_args(args) + result = [] + + s_extra = Gem::Command.specific_extra_args(@command) + extra = Gem::Command.extra_args + s_extra + + until extra.empty? do + ex = [] + ex << extra.shift + ex << extra.shift if /^[^-]/.match?(extra.first.to_s) + result << ex if handles?(ex) + end + + result.flatten! + result.concat(args) + result + end + + def deprecated? + false + end + + private + + def option_is_deprecated?(option) + @deprecated_options[command].key?(option) + end + + def add_parser_description # :nodoc: + return unless description + + formatted = description.split("\n\n").map do |chunk| + wrap chunk, 80 - 4 + end.join "\n" + + @parser.separator nil + @parser.separator " Description:" + formatted.each_line do |line| + @parser.separator " #{line.rstrip}" + end + end + + def add_parser_options # :nodoc: + @parser.separator nil + + regular_options = @option_groups.delete :options + + configure_options "", regular_options + + @option_groups.sort_by {|n,_| n.to_s }.each do |group_name, option_list| + @parser.separator nil + configure_options group_name, option_list + end + end + + ## + # Adds a section with +title+ and +content+ to the parser help view. Used + # for adding command arguments and default arguments. + + def add_parser_run_info(title, content) + return if content.empty? + + @parser.separator nil + @parser.separator " #{title}:" + content.each_line do |line| + @parser.separator " #{line.rstrip}" + end + end + + def add_parser_summary # :nodoc: + return unless @summary + + @parser.separator nil + @parser.separator " Summary:" + wrap(@summary, 80 - 4).each_line do |line| + @parser.separator " #{line.strip}" + end + end + + ## + # Create on demand parser. + + def parser + create_option_parser if @parser.nil? + @parser + end + + ## + # Creates an option parser and fills it in with the help info for the + # command. + + def create_option_parser + @parser = Gem::OptionParser.new + + add_parser_options + + @parser.separator nil + configure_options "Common", Gem::Command.common_options + + add_parser_run_info "Arguments", arguments + add_parser_summary + add_parser_description + add_parser_run_info "Defaults", defaults_str + end + + def configure_options(header, option_list) + return if option_list.nil? || option_list.empty? + + header = header.to_s.empty? ? "" : "#{header} " + @parser.separator " #{header}Options:" + + option_list.each do |args, handler| + @parser.on(*args) do |value| + handler.call(value, @options) + end + end + + @parser.separator "" + end + + ## + # Wraps +text+ to +width+ + + def wrap(text, width) # :doc: + text.gsub(/(.{1,#{width}})( +|$\n?)|(.{1,#{width}})/, "\\1\\3\n") + end + + # ---------------------------------------------------------------- + # Add the options common to all commands. + + add_common_option("-h", "--help", + "Get help on this command") do |_value, options| + options[:help] = true + end + + add_common_option("-V", "--[no-]verbose", + "Set the verbose level of output") do |value, _options| + # Set us to "really verbose" so the progress meter works + if Gem.configuration.verbose && value + Gem.configuration.verbose = 1 + else + Gem.configuration.verbose = value + end + end + + add_common_option("-q", "--quiet", "Silence command progress meter") do |_value, _options| + Gem.configuration.verbose = false + end + + add_common_option("--silent", + "Silence RubyGems output") do |_value, options| + options[:silent] = true + end + + # Backtrace and config-file are added so they show up in the help + # commands. Both options are actually handled before the other + # options get parsed. + + add_common_option("--config-file FILE", + "Use this config file instead of default") do + end + + add_common_option("--backtrace", + "Show stack backtrace on errors") do + end + + add_common_option("--debug", + "Turn on Ruby debugging") do + end + + add_common_option("--norc", + "Avoid loading any .gemrc file") do + end + + # :stopdoc: + + HELP = <<-HELP +RubyGems is a package manager for Ruby. + + Usage: + gem -h/--help + gem -v/--version + gem [global options...] command [arguments...] [options...] + + Global options: + -C PATH run as if gem was started in <PATH> + instead of the current working directory + + Examples: + gem install rake + gem list --local + gem build package.gemspec + gem push package-0.0.1.gem + gem help install + + Further help: + gem help commands list all 'gem' commands + gem help examples show some examples of usage + gem help gem_dependencies gem dependencies file guide + gem help platforms gem platforms guide + gem help <COMMAND> show help on COMMAND + (e.g. 'gem help install') + Further information: + https://guides.rubygems.org + HELP + + # :startdoc: +end + +## +# \Commands will be placed in this namespace + +module Gem::Commands +end diff --git a/lib/rubygems/command_manager.rb b/lib/rubygems/command_manager.rb new file mode 100644 index 0000000000..76b2fba835 --- /dev/null +++ b/lib/rubygems/command_manager.rb @@ -0,0 +1,254 @@ +# 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 "command" +require_relative "user_interaction" +require_relative "text" + +## +# The command manager registers and installs all the individual sub-commands +# supported by the gem command. +# +# Extra commands can be provided by writing a rubygems_plugin.rb +# file in an installed gem. You should register your command against the +# Gem::CommandManager instance, like this: +# +# # file rubygems_plugin.rb +# require 'rubygems/command_manager' +# +# Gem::CommandManager.instance.register_command :edit +# +# You should put the implementation of your command in rubygems/commands. +# +# # file rubygems/commands/edit_command.rb +# class Gem::Commands::EditCommand < Gem::Command +# # ... +# end +# +# See Gem::Command for instructions on writing gem commands. + +class Gem::CommandManager + include Gem::Text + include Gem::UserInteraction + + BUILTIN_COMMANDS = [ # :nodoc: + :build, + :cert, + :check, + :cleanup, + :contents, + :dependency, + :environment, + :exec, + :fetch, + :generate_index, + :help, + :info, + :install, + :list, + :lock, + :mirror, + :open, + :outdated, + :owner, + :pristine, + :push, + :rdoc, + :rebuild, + :search, + :server, + :signin, + :signout, + :sources, + :specification, + :stale, + :uninstall, + :unpack, + :update, + :which, + :yank, + ].freeze + + ALIAS_COMMANDS = { + "i" => "install", + "login" => "signin", + "logout" => "signout", + }.freeze + + ## + # Return the authoritative instance of the command manager. + + def self.instance + @instance ||= new + end + + ## + # Returns self. Allows a CommandManager instance to stand + # in for the class itself. + + def instance + self + end + + ## + # Reset the authoritative instance of the command manager. + + def self.reset + @instance = nil + end + + ## + # Register all the subcommands supported by the gem command. + + def initialize + require_relative "vendored_timeout" + @commands = {} + + BUILTIN_COMMANDS.each do |name| + register_command name + end + end + + ## + # Register the Symbol +command+ as a gem command. + + def register_command(command, obj = false) + @commands[command] = obj + end + + ## + # Unregister the Symbol +command+ as a gem command. + + def unregister_command(command) + @commands.delete command + end + + ## + # Returns a Command instance for +command_name+ + + def [](command_name) + command_name = command_name.intern + return nil if @commands[command_name].nil? + @commands[command_name] ||= load_and_instantiate(command_name) + end + + ## + # Return a sorted list of all command names as strings. + + def command_names + @commands.keys.collect(&:to_s).sort + end + + ## + # Run the command specified by +args+. + + def run(args, build_args = nil) + process_args(args, build_args) + rescue StandardError, Gem::Timeout::Error => ex + if ex.respond_to?(:detailed_message) + msg = ex.detailed_message(highlight: false).sub(/\A(.*?)(?: \(.+?\))/) { $1 } + else + msg = ex.message + end + alert_error clean_text("While executing gem ... (#{ex.class})\n #{msg}") + ui.backtrace ex + + terminate_interaction(1) + rescue Interrupt + alert_error clean_text("Interrupted") + terminate_interaction(1) + end + + def process_args(args, build_args = nil) + if args.empty? + say Gem::Command::HELP + terminate_interaction 1 + end + + case args.first + when "-h", "--help" then + say Gem::Command::HELP + terminate_interaction 0 + when "-v", "--version" then + say Gem::VERSION + terminate_interaction 0 + when "-C" then + args.shift + start_point = args.shift + if Dir.exist?(start_point) + Dir.chdir(start_point) { invoke_command(args, build_args) } + else + alert_error clean_text("#{start_point} isn't a directory.") + terminate_interaction 1 + end + when /^-/ then + alert_error clean_text("Invalid option: #{args.first}. See 'gem --help'.") + terminate_interaction 1 + else + invoke_command(args, build_args) + end + end + + def find_command(cmd_name) + cmd_name = find_alias_command cmd_name + + possibilities = find_command_possibilities cmd_name + + if possibilities.size > 1 + raise Gem::CommandLineError, + "Ambiguous command #{cmd_name} matches [#{possibilities.join(", ")}]" + elsif possibilities.empty? + raise Gem::UnknownCommandError.new(cmd_name) + end + + self[possibilities.first] + end + + def find_alias_command(cmd_name) + alias_name = ALIAS_COMMANDS[cmd_name] + alias_name ? alias_name : cmd_name + end + + def find_command_possibilities(cmd_name) + len = cmd_name.length + + found = command_names.select {|name| cmd_name == name[0, len] } + + exact = found.find {|name| name == cmd_name } + + exact ? [exact] : found + end + + private + + def load_and_instantiate(command_name) + command_name = command_name.to_s + const_name = command_name.capitalize.gsub(/_(.)/) { $1.upcase } << "Command" + + begin + begin + require "rubygems/commands/#{command_name}_command" + rescue LoadError + # it may have been defined from a rubygems_plugin.rb file + end + + Gem::Commands.const_get(const_name).new + rescue StandardError => e + alert_error clean_text("Loading command: #{command_name} (#{e.class})\n\t#{e}") + ui.backtrace e + end + end + + def invoke_command(args, build_args) + cmd_name = args.shift.downcase + cmd = find_command cmd_name + terminate_interaction 1 unless cmd + cmd.deprecation_warning if cmd.deprecated? + cmd.invoke_with_build_args args, build_args + end +end diff --git a/lib/rubygems/commands/build_command.rb b/lib/rubygems/commands/build_command.rb new file mode 100644 index 0000000000..cfe1f8ec3c --- /dev/null +++ b/lib/rubygems/commands/build_command.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../gemspec_helpers" +require_relative "../package" +require_relative "../version_option" + +class Gem::Commands::BuildCommand < Gem::Command + include Gem::VersionOption + include Gem::GemspecHelpers + + def initialize + super "build", "Build a gem from a gemspec" + + add_platform_option + + add_option "--force", "skip validation of the spec" do |_value, options| + options[:force] = true + end + + add_option "--strict", "consider warnings as errors when validating the spec" do |_value, options| + options[:strict] = true + end + + add_option "-o", "--output FILE", "output gem with the given filename" do |value, options| + options[:output] = value + end + end + + def arguments # :nodoc: + "GEMSPEC_FILE gemspec file name to build a gem for" + end + + def description # :nodoc: + <<-EOF +The build command allows you to create a gem from a ruby gemspec. + +The best way to build a gem is to use a Rakefile and the Gem::PackageTask +which ships with RubyGems. + +The gemspec can either be created by hand or extracted from an existing gem +with gem spec: + + $ gem unpack my_gem-1.0.gem + Unpacked gem: '.../my_gem-1.0' + $ gem spec my_gem-1.0.gem --ruby > my_gem-1.0/my_gem-1.0.gemspec + $ cd my_gem-1.0 + [edit gem contents] + $ gem build my_gem-1.0.gemspec + +Gems can be saved to a specified filename with the output option: + + $ gem build my_gem-1.0.gemspec --output=release.gem + + EOF + end + + def usage # :nodoc: + "#{program_name} GEMSPEC_FILE" + end + + def execute + if build_path = options[:build_path] + Dir.chdir(build_path) { build_gem } + return + end + + build_gem + end + + private + + def build_gem + gemspec = resolve_gem_name + + if gemspec + build_package(gemspec) + else + alert_error error_message + terminate_interaction(1) + end + end + + def build_package(gemspec) + spec = Gem::Specification.load(gemspec) + if spec + Gem::Package.build( + spec, + options[:force], + options[:strict], + options[:output] + ) + else + alert_error "Error loading gemspec. Aborting." + terminate_interaction 1 + end + end + + def resolve_gem_name + return find_gemspec unless gem_name + + if File.exist?(gem_name) + gem_name + else + find_gemspec("#{gem_name}.gemspec") || find_gemspec(gem_name) + end + end + + def error_message + if gem_name + "Couldn't find a gemspec file matching '#{gem_name}' in #{Dir.pwd}" + else + "Couldn't find a gemspec file in #{Dir.pwd}" + end + end + + def gem_name + get_one_optional_argument + end +end diff --git a/lib/rubygems/commands/cert_command.rb b/lib/rubygems/commands/cert_command.rb new file mode 100644 index 0000000000..fe03841ddb --- /dev/null +++ b/lib/rubygems/commands/cert_command.rb @@ -0,0 +1,325 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../security" + +class Gem::Commands::CertCommand < Gem::Command + def initialize + super "cert", "Manage RubyGems certificates and signing settings", + add: [], remove: [], list: [], build: [], sign: [] + + add_option("-a", "--add CERT", + "Add a trusted certificate.") do |cert_file, options| + options[:add] << open_cert(cert_file) + end + + add_option("-l", "--list [FILTER]", + "List trusted certificates where the", + "subject contains FILTER") do |filter, options| + filter ||= "" + + options[:list] << filter + end + + add_option("-r", "--remove FILTER", + "Remove trusted certificates where the", + "subject contains FILTER") do |filter, options| + options[:remove] << filter + end + + add_option("-b", "--build EMAIL_ADDR", + "Build private key and self-signed", + "certificate for EMAIL_ADDR") do |email_address, options| + options[:build] << email_address + end + + add_option("-C", "--certificate CERT", + "Signing certificate for --sign") do |cert_file, options| + options[:issuer_cert] = open_cert(cert_file) + options[:issuer_cert_file] = cert_file + end + + add_option("-K", "--private-key KEY", + "Key for --sign or --build") do |key_file, options| + options[:key] = open_private_key(key_file) + end + + add_option("-A", "--key-algorithm ALGORITHM", + "Select which key algorithm to use for --build") do |algorithm, options| + options[:key_algorithm] = algorithm + end + + add_option("-s", "--sign CERT", + "Signs CERT with the key from -K", + "and the certificate from -C") do |cert_file, options| + raise Gem::OptionParser::InvalidArgument, "#{cert_file}: does not exist" unless + File.file? cert_file + + options[:sign] << cert_file + end + + add_option("-d", "--days NUMBER_OF_DAYS", + "Days before the certificate expires") do |days, options| + options[:expiration_length_days] = days.to_i + end + + add_option("-R", "--re-sign", + "Re-signs the certificate from -C with the key from -K") do |resign, options| + options[:resign] = resign + end + end + + def add_certificate(certificate) # :nodoc: + Gem::Security.trust_dir.trust_cert certificate + + say "Added '#{certificate.subject}'" + end + + def check_openssl + return if Gem::HAVE_OPENSSL + + alert_error "OpenSSL library is required for the cert command" + terminate_interaction 1 + end + + def open_cert(certificate_file) + check_openssl + OpenSSL::X509::Certificate.new File.read certificate_file + rescue Errno::ENOENT + raise Gem::OptionParser::InvalidArgument, "#{certificate_file}: does not exist" + rescue OpenSSL::X509::CertificateError + raise Gem::OptionParser::InvalidArgument, + "#{certificate_file}: invalid X509 certificate" + end + + def open_private_key(key_file) + check_openssl + passphrase = ENV["GEM_PRIVATE_KEY_PASSPHRASE"] + key = OpenSSL::PKey.read File.read(key_file), passphrase + raise Gem::OptionParser::InvalidArgument, + "#{key_file}: private key not found" unless key.private? + key + rescue Errno::ENOENT + raise Gem::OptionParser::InvalidArgument, "#{key_file}: does not exist" + rescue OpenSSL::PKey::PKeyError, ArgumentError + raise Gem::OptionParser::InvalidArgument, "#{key_file}: invalid RSA, DSA, or EC key" + end + + def execute + check_openssl + + options[:add].each do |certificate| + add_certificate certificate + end + + options[:remove].each do |filter| + remove_certificates_matching filter + end + + options[:list].each do |filter| + list_certificates_matching filter + end + + options[:build].each do |email| + build email + end + + if options[:resign] + re_sign_cert( + options[:issuer_cert], + options[:issuer_cert_file], + options[:key] + ) + end + + sign_certificates unless options[:sign].empty? + end + + def build(email) + unless valid_email?(email) + raise Gem::CommandLineError, "Invalid email address #{email}" + end + + key, key_path = build_key + cert_path = build_cert email, key + + say "Certificate: #{cert_path}" + + if key_path + say "Private Key: #{key_path}" + say "Don't forget to move the key file to somewhere private!" + end + end + + def build_cert(email, key) # :nodoc: + expiration_length_days = options[:expiration_length_days] || + Gem.configuration.cert_expiration_length_days + + cert = Gem::Security.create_cert_email( + email, + key, + Gem::Security::ONE_DAY * expiration_length_days + ) + + Gem::Security.write cert, "gem-public_cert.pem" + end + + def build_key # :nodoc: + return options[:key] if options[:key] + + passphrase = ask_for_password "Passphrase for your Private Key:" + say "\n" + + passphrase_confirmation = ask_for_password "Please repeat the passphrase for your Private Key:" + say "\n" + + raise Gem::CommandLineError, + "Passphrase and passphrase confirmation don't match" unless passphrase == passphrase_confirmation + + algorithm = options[:key_algorithm] || Gem::Security::DEFAULT_KEY_ALGORITHM + key = Gem::Security.create_key(algorithm) + key_path = Gem::Security.write key, "gem-private_key.pem", 0o600, passphrase + + [key, key_path] + end + + def certificates_matching(filter) + return enum_for __method__, filter unless block_given? + + Gem::Security.trusted_certificates.select do |certificate, _| + subject = certificate.subject.to_s + subject.downcase.index filter + end.sort_by do |certificate, _| + certificate.subject.to_a.map {|name, data,| [name, data] } + end.each do |certificate, path| + yield certificate, path + end + end + + def description # :nodoc: + <<-EOF +The cert command manages signing keys and certificates for creating signed +gems. Your signing certificate and private key are typically stored in +~/.gem/gem-public_cert.pem and ~/.gem/gem-private_key.pem respectively. + +To build a certificate for signing gems: + + gem cert --build you@example + +If you already have an RSA key, or are creating a new certificate for an +existing key: + + gem cert --build you@example --private-key /path/to/key.pem + +If you wish to trust a certificate you can add it to the trust list with: + + gem cert --add /path/to/cert.pem + +You can list trusted certificates with: + + gem cert --list + +or: + + gem cert --list cert_subject_substring + +If you wish to remove a previously trusted certificate: + + gem cert --remove cert_subject_substring + +To sign another gem author's certificate: + + gem cert --sign /path/to/other_cert.pem + +For further reading on signing gems see `ri Gem::Security`. + EOF + end + + def list_certificates_matching(filter) # :nodoc: + certificates_matching filter do |certificate, _| + # this could probably be formatted more gracefully + say certificate.subject.to_s + end + end + + def load_default_cert + cert_file = File.join Gem.default_cert_path + cert = File.read cert_file + options[:issuer_cert] = OpenSSL::X509::Certificate.new cert + rescue Errno::ENOENT + alert_error \ + "--certificate not specified and ~/.gem/gem-public_cert.pem does not exist" + + terminate_interaction 1 + rescue OpenSSL::X509::CertificateError + alert_error \ + "--certificate not specified and ~/.gem/gem-public_cert.pem is not valid" + + terminate_interaction 1 + end + + def load_default_key + key_file = File.join Gem.default_key_path + key = File.read key_file + passphrase = ENV["GEM_PRIVATE_KEY_PASSPHRASE"] + options[:key] = OpenSSL::PKey.read key, passphrase + rescue Errno::ENOENT + alert_error \ + "--private-key not specified and ~/.gem/gem-private_key.pem does not exist" + + terminate_interaction 1 + rescue OpenSSL::PKey::PKeyError + alert_error \ + "--private-key not specified and ~/.gem/gem-private_key.pem is not valid" + + terminate_interaction 1 + end + + def load_defaults # :nodoc: + load_default_cert unless options[:issuer_cert] + load_default_key unless options[:key] + end + + def remove_certificates_matching(filter) # :nodoc: + certificates_matching filter do |certificate, path| + FileUtils.rm path + say "Removed '#{certificate.subject}'" + end + end + + def sign(cert_file) + cert = File.read cert_file + cert = OpenSSL::X509::Certificate.new cert + + permissions = File.stat(cert_file).mode & 0o777 + + issuer_cert = options[:issuer_cert] + issuer_key = options[:key] + + cert = Gem::Security.sign cert, issuer_key, issuer_cert + + Gem::Security.write cert, cert_file, permissions + end + + def sign_certificates # :nodoc: + load_defaults unless options[:sign].empty? + + options[:sign].each do |cert_file| + sign cert_file + end + end + + def re_sign_cert(cert, cert_path, private_key) + Gem::Security::Signer.re_sign_cert(cert, cert_path, private_key) do |expired_cert_path, new_expired_cert_path| + alert("Your certificate #{expired_cert_path} has been re-signed") + alert("Your expired certificate will be located at: #{new_expired_cert_path}") + end + end + + private + + def valid_email?(email) + # It's simple, but is all we need + email =~ /\A.+@.+\z/ + end +end diff --git a/lib/rubygems/commands/check_command.rb b/lib/rubygems/commands/check_command.rb new file mode 100644 index 0000000000..fb23dd9cb4 --- /dev/null +++ b/lib/rubygems/commands/check_command.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../version_option" +require_relative "../validator" +require_relative "../doctor" + +class Gem::Commands::CheckCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "check", "Check a gem repository for added or missing files", + alien: true, doctor: false, dry_run: false, gems: true + + add_option("-a", "--[no-]alien", + 'Report "unmanaged" or rogue files in the', + "gem repository") do |value, options| + options[:alien] = value + end + + add_option("--[no-]doctor", + "Clean up uninstalled gems and broken", + "specifications") do |value, options| + options[:doctor] = value + end + + add_option("--[no-]dry-run", + "Do not remove files, only report what", + "would be removed") do |value, options| + options[:dry_run] = value + end + + add_option("--[no-]gems", + "Check installed gems for problems") do |value, options| + options[:gems] = value + end + + add_version_option "check" + end + + def check_gems + say "Checking gems..." + say + gems = begin + get_all_gem_names + rescue StandardError + [] + end + + Gem::Validator.new.alien(gems).sort.each do |key, val| + if val.empty? + say "#{key} is error-free" if Gem.configuration.verbose + else + say "#{key} has #{val.size} problems" + val.each do |error_entry| + say " #{error_entry.path}:" + say " #{error_entry.problem}" + end + end + say + end + end + + def doctor + say "Checking for files from uninstalled gems..." + say + + Gem.path.each do |gem_repo| + doctor = Gem::Doctor.new gem_repo, options[:dry_run] + doctor.doctor + end + end + + def execute + check_gems if options[:gems] + doctor if options[:doctor] + end + + def arguments # :nodoc: + "GEMNAME name of gem to check" + end + + def defaults_str # :nodoc: + "--gems --alien" + end + + def description # :nodoc: + <<-EOF +The check command can list and repair problems with installed gems and +specifications and will clean up gems that have been partially uninstalled. + EOF + end + + def usage # :nodoc: + "#{program_name} [OPTIONS] [GEMNAME ...]" + end +end diff --git a/lib/rubygems/commands/cleanup_command.rb b/lib/rubygems/commands/cleanup_command.rb new file mode 100644 index 0000000000..c89a24eee9 --- /dev/null +++ b/lib/rubygems/commands/cleanup_command.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../dependency_list" +require_relative "../uninstaller" + +class Gem::Commands::CleanupCommand < Gem::Command + def initialize + super "cleanup", + "Clean up old versions of installed gems", + force: false, install_dir: Gem.dir, + check_dev: true + + add_option("-n", "-d", "--dry-run", + "Do not uninstall gems") do |_value, options| + options[:dryrun] = true + end + + add_option(:Deprecated, "--dryrun", + "Do not uninstall gems") do |_value, options| + options[:dryrun] = true + end + deprecate_option("--dryrun", extra_msg: "Use --dry-run instead") + + add_option("-D", "--[no-]check-development", + "Check development dependencies while uninstalling", + "(default: true)") do |value, options| + options[:check_dev] = value + end + + add_option("--[no-]user-install", + "Cleanup in user's home directory instead", + "of GEM_HOME.") do |value, options| + options[:user_install] = value + end + + @candidate_gems = nil + @default_gems = [] + @full = nil + @gems_to_cleanup = nil + @primary_gems = nil + end + + def arguments # :nodoc: + "GEMNAME name of gem to cleanup" + end + + def defaults_str # :nodoc: + "--no-dry-run" + end + + def description # :nodoc: + <<-EOF +The cleanup command removes old versions of gems from GEM_HOME that are not +required to meet a dependency. If a gem is installed elsewhere in GEM_PATH +the cleanup command won't delete it. + +If no gems are named all gems in GEM_HOME are cleaned. + EOF + end + + def usage # :nodoc: + "#{program_name} [GEMNAME ...]" + end + + def execute + say "Cleaning up installed gems..." + + if options[:args].empty? + done = false + last_set = nil + + until done do + clean_gems + + this_set = @gems_to_cleanup.map(&:full_name).sort + + done = this_set.empty? || last_set == this_set + + last_set = this_set + end + else + clean_gems + end + + say "Clean up complete" + + verbose do + skipped = @default_gems.map(&:full_name) + + "Skipped default gems: #{skipped.join ", "}" + end + end + + def clean_gems + get_primary_gems + get_candidate_gems + get_gems_to_cleanup + + @full = Gem::DependencyList.from_specs + + deplist = Gem::DependencyList.new + @gems_to_cleanup.each {|spec| deplist.add spec } + + deps = deplist.strongly_connected_components.flatten + + deps.reverse_each do |spec| + uninstall_dep spec + end + end + + def get_candidate_gems + @candidate_gems = if options[:args].empty? + Gem::Specification.to_a + else + options[:args].flat_map do |gem_name| + Gem::Specification.find_all_by_name gem_name + end + end + end + + def get_gems_to_cleanup + gems_to_cleanup = @candidate_gems.select do |spec| + @primary_gems[spec.name].version != spec.version + end + + default_gems, gems_to_cleanup = gems_to_cleanup.partition(&:default_gem?) + + uninstall_from = options[:user_install] ? Gem.user_dir : Gem.dir + + gems_to_cleanup = gems_to_cleanup.select do |spec| + spec.base_dir == uninstall_from + end + + @default_gems += default_gems + @default_gems.uniq! + @gems_to_cleanup = gems_to_cleanup.uniq + end + + def get_primary_gems + @primary_gems = {} + + Gem::Specification.each do |spec| + if @primary_gems[spec.name].nil? || + @primary_gems[spec.name].version < spec.version + @primary_gems[spec.name] = spec + end + end + end + + def uninstall_dep(spec) + return unless @full.ok_to_remove?(spec.full_name, options[:check_dev]) + + if options[:dryrun] + say "Dry Run Mode: Would uninstall #{spec.full_name}" + return + end + + say "Attempting to uninstall #{spec.full_name}" + + uninstall_options = { + executables: false, + version: "= #{spec.version}", + } + + uninstall_options[:user_install] = Gem.user_dir == spec.base_dir + + uninstaller = Gem::Uninstaller.new spec.name, uninstall_options + + begin + uninstaller.uninstall + rescue Gem::DependencyRemovalException, Gem::InstallError, + Gem::GemNotInHomeException, Gem::FilePermissionError => e + say "Unable to uninstall #{spec.full_name}:" + say "\t#{e.class}: #{e.message}" + end + end +end diff --git a/lib/rubygems/commands/contents_command.rb b/lib/rubygems/commands/contents_command.rb new file mode 100644 index 0000000000..d4f9871868 --- /dev/null +++ b/lib/rubygems/commands/contents_command.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../version_option" + +class Gem::Commands::ContentsCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "contents", "Display the contents of the installed gems", + specdirs: [], lib_only: false, prefix: true, + show_install_dir: false + + add_version_option + + add_option("--all", + "Contents for all gems") do |all, options| + options[:all] = all + end + + add_option("-s", "--spec-dir a,b,c", Array, + "Search for gems under specific paths") do |spec_dirs, options| + options[:specdirs] = spec_dirs + end + + add_option("-l", "--[no-]lib-only", + "Only return files in the Gem's lib_dirs") do |lib_only, options| + options[:lib_only] = lib_only + end + + add_option("--[no-]prefix", + "Don't include installed path prefix") do |prefix, options| + options[:prefix] = prefix + end + + add_option("--[no-]show-install-dir", + "Show only the gem install dir") do |show, options| + options[:show_install_dir] = show + end + + @path_kind = nil + @spec_dirs = nil + @version = nil + end + + def arguments # :nodoc: + "GEMNAME name of gem to list contents for" + end + + def defaults_str # :nodoc: + "--no-lib-only --prefix" + end + + def description # :nodoc: + <<-EOF +The contents command lists the files in an installed gem. The listing can +be given as full file names, file names without the installed directory +prefix or only the files that are requireable. + EOF + end + + def usage # :nodoc: + "#{program_name} GEMNAME [GEMNAME ...]" + end + + def execute + @version = options[:version] || Gem::Requirement.default + @spec_dirs = specification_directories + @path_kind = path_description @spec_dirs + + names = gem_names + + names.each do |name| + found = + if options[:show_install_dir] + gem_install_dir name + else + gem_contents name + end + + terminate_interaction 1 unless found || names.length > 1 + end + end + + def files_in(spec) + if spec.default_gem? + files_in_default_gem spec + else + files_in_gem spec + end + end + + def files_in_gem(spec) + gem_path = spec.full_gem_path + extra = "/{#{spec.require_paths.join ","}}" if options[:lib_only] + glob = "#{gem_path}#{extra}/**/*" + prefix_re = %r{#{Regexp.escape(gem_path)}/} + + Dir[glob].map do |file| + [gem_path, file.sub(prefix_re, "")] + end + end + + def files_in_default_gem(spec) + spec.files.filter_map do |file| + if file.start_with?("#{spec.bindir}/") + [RbConfig::CONFIG["bindir"], file.delete_prefix("#{spec.bindir}/")] + else + gem spec.name, spec.version + + require_path = spec.require_paths.find do |path| + file.start_with?("#{path}/") + end + + requirable_part = file.delete_prefix("#{require_path}/") + + resolve = $LOAD_PATH.resolve_feature_path(requirable_part)&.last + next unless resolve + + [resolve.delete_suffix(requirable_part), requirable_part] + end + end + end + + def gem_contents(name) + spec = spec_for name + + return false unless spec + + files = files_in spec + + show_files files + + true + end + + def gem_install_dir(name) + spec = spec_for name + + return false unless spec + + say spec.gem_dir + + true + end + + def gem_names # :nodoc: + if options[:all] + Gem::Specification.map(&:name) + else + get_all_gem_names + end + end + + def path_description(spec_dirs) # :nodoc: + if spec_dirs.empty? + "default gem paths" + else + "specified path" + end + end + + def show_files(files) + files.sort.each do |prefix, basename| + absolute_path = File.join(prefix, basename) + next if File.directory? absolute_path + + if options[:prefix] + say absolute_path + else + say basename + end + end + end + + def spec_for(name) + spec = Gem::Specification.find_all_by_name(name, @version).first + + return spec if spec + + say "Unable to find gem '#{name}' in #{@path_kind}" + + if Gem.configuration.verbose + say "\nDirectories searched:" + @spec_dirs.sort.each {|dir| say dir } + end + + nil + end + + def specification_directories # :nodoc: + options[:specdirs].flat_map do |i| + [i, File.join(i, "specifications")] + end + end +end diff --git a/lib/rubygems/commands/dependency_command.rb b/lib/rubygems/commands/dependency_command.rb new file mode 100644 index 0000000000..9aaefae999 --- /dev/null +++ b/lib/rubygems/commands/dependency_command.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../version_option" + +class Gem::Commands::DependencyCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::VersionOption + + def initialize + super "dependency", + "Show the dependencies of an installed gem", + version: Gem::Requirement.default, domain: :local + + add_version_option + add_platform_option + add_prerelease_option + + add_option("-R", "--[no-]reverse-dependencies", + "Include reverse dependencies in the output") do |value, options| + options[:reverse_dependencies] = value + end + + add_option("-p", "--pipe", + "Pipe Format (name --version ver)") do |value, options| + options[:pipe_format] = value + end + + add_local_remote_options + end + + def arguments # :nodoc: + "REGEXP show dependencies for gems whose names start with REGEXP" + end + + def defaults_str # :nodoc: + "--local --version '#{Gem::Requirement.default}' --no-reverse-dependencies" + end + + def description # :nodoc: + <<-EOF +The dependency commands lists which other gems a given gem depends on. For +local gems only the reverse dependencies can be shown (which gems depend on +the named gem). + +The dependency list can be displayed in a format suitable for piping for +use with other commands. + EOF + end + + def usage # :nodoc: + "#{program_name} REGEXP" + end + + def fetch_remote_specs(name, requirement, prerelease) # :nodoc: + fetcher = Gem::SpecFetcher.fetcher + + specs_type = prerelease ? :complete : :released + + ss = if name.nil? + fetcher.detect(specs_type) { true } + else + fetcher.detect(specs_type) do |name_tuple| + name === name_tuple.name && requirement.satisfied_by?(name_tuple.version) + end + end + + ss.map {|tuple, source| source.fetch_spec(tuple) } + end + + def fetch_specs(name_pattern, requirement, prerelease) # :nodoc: + specs = [] + + if local? + specs.concat Gem::Specification.stubs.find_all {|spec| + name_matches = name_pattern ? name_pattern =~ spec.name : true + version_matches = requirement.satisfied_by?(spec.version) + + name_matches && version_matches + }.map(&:to_spec) + end + + specs.concat fetch_remote_specs name_pattern, requirement, prerelease if remote? + + ensure_specs specs + + specs.uniq.sort + end + + def display_pipe(specs) # :nodoc: + specs.each do |spec| + next if spec.dependencies.empty? + spec.dependencies.sort_by(&:name).each do |dep| + say "#{dep.name} --version '#{dep.requirement}'" + end + end + end + + def display_readable(specs, reverse) # :nodoc: + response = String.new + + specs.each do |spec| + response << print_dependencies(spec) + unless reverse[spec.full_name].empty? + response << " Used by\n" + reverse[spec.full_name].each do |sp, dep| + response << " #{sp} (#{dep})\n" + end + end + response << "\n" + end + + say response + end + + def execute + ensure_local_only_reverse_dependencies + + pattern = name_pattern options[:args] + requirement = Gem::Requirement.new options[:version] + + specs = fetch_specs pattern, requirement, options[:prerelease] + + reverse = reverse_dependencies specs + + if options[:pipe_format] + display_pipe specs + else + display_readable specs, reverse + end + end + + def ensure_local_only_reverse_dependencies # :nodoc: + if options[:reverse_dependencies] && remote? && !local? + alert_error "Only reverse dependencies for local gems are supported." + terminate_interaction 1 + end + end + + def ensure_specs(specs) # :nodoc: + return unless specs.empty? + + patterns = options[:args].join "," + say "No gems found matching #{patterns} (#{options[:version]})" if + Gem.configuration.verbose + + terminate_interaction 1 + end + + def print_dependencies(spec, level = 0) # :nodoc: + response = String.new + response << " " * level + "Gem #{spec.full_name}\n" + unless spec.dependencies.empty? + spec.dependencies.sort_by(&:name).each do |dep| + response << " " * level + " #{dep}\n" + end + end + response + end + + def reverse_dependencies(specs) # :nodoc: + reverse = Hash.new {|h, k| h[k] = [] } + + return reverse unless options[:reverse_dependencies] + + specs.each do |spec| + reverse[spec.full_name] = find_reverse_dependencies spec + end + + reverse + end + + ## + # Returns an Array of [specification, dep] that are satisfied by +spec+. + + def find_reverse_dependencies(spec) # :nodoc: + result = [] + + Gem::Specification.each do |sp| + sp.dependencies.each do |dep| + dep = Gem::Dependency.new(*dep) unless Gem::Dependency === dep + + if spec.name == dep.name && + dep.requirement.satisfied_by?(spec.version) + result << [sp.full_name, dep] + end + end + end + + result + end + + private + + def name_pattern(args) + return if args.empty? + + if args.length == 1 && args.first =~ /\A(.*)(i)?\z/m + flags = $2 ? Regexp::IGNORECASE : nil + Regexp.new $1, flags + else + /\A#{Regexp.union(*args)}/ + end + end +end diff --git a/lib/rubygems/commands/environment_command.rb b/lib/rubygems/commands/environment_command.rb new file mode 100644 index 0000000000..a5eb521a53 --- /dev/null +++ b/lib/rubygems/commands/environment_command.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require_relative "../command" + +class Gem::Commands::EnvironmentCommand < Gem::Command + def initialize + super "environment", "Display information about the RubyGems environment" + end + + def arguments # :nodoc: + args = <<-EOF + home display the path where gems are installed. Aliases: gemhome, gemdir, GEM_HOME + path display path used to search for gems. Aliases: gempath, GEM_PATH + user_gemhome display the path where gems are installed when `--user-install` is given. Aliases: user_gemdir + version display the gem format version + remotesources display the remote gem servers + platform display the supported gem platforms + credentials display the path where credentials are stored + <omitted> display everything + EOF + args.gsub(/^\s+/, "") + end + + def description # :nodoc: + <<-EOF +The environment command lets you query rubygems for its configuration for +use in shell scripts or as a debugging aid. + +The RubyGems environment can be controlled through command line arguments, +gemrc files, environment variables and built-in defaults. + +Command line argument defaults and some RubyGems defaults can be set in a +~/.gemrc file for individual users and a gemrc in the SYSTEM CONFIGURATION +DIRECTORY for all users. These files are YAML files with the following YAML +keys: + + :sources: A YAML array of remote gem repositories to install gems from + :verbose: Verbosity of the gem command. false, true, and :really are the + levels + :update_sources: Enable/disable automatic updating of repository metadata + :concurrent_downloads: The number of gem downloads to perform concurrently + :backtrace: Print backtrace when RubyGems encounters an error + :gempath: The paths in which to look for gems + :disable_default_gem_server: Force specification of gem server host on push + <gem_command>: A string containing arguments for the specified gem command + +Example: + + :verbose: false + install: --no-wrappers + update: --no-wrappers + :disable_default_gem_server: true + +RubyGems' default local repository can be overridden with the GEM_PATH and +GEM_HOME environment variables. GEM_HOME sets the default repository to +install into. GEM_PATH allows multiple local repositories to be searched for +gems. + +If you are behind a proxy server, RubyGems uses the HTTP_PROXY, +HTTP_PROXY_USER and HTTP_PROXY_PASS environment variables to discover the +proxy server. + +If you would like to push gems to a private gem server the RUBYGEMS_HOST +environment variable can be set to the URI for that server. + +If you are packaging RubyGems all of RubyGems' defaults are in +lib/rubygems/defaults.rb. You may override these in +lib/rubygems/defaults/operating_system.rb + EOF + end + + def usage # :nodoc: + "#{program_name} [arg]" + end + + def execute + out = String.new + arg = options[:args][0] + out << + case arg + when /^version/ then + Gem::VERSION + when /^gemdir/, /^gemhome/, /^home/, /^GEM_HOME/ then + Gem.dir + when /^gempath/, /^path/, /^GEM_PATH/ then + Gem.path.join(File::PATH_SEPARATOR) + when /^user_gemdir/, /^user_gemhome/ then + Gem.user_dir + when /^remotesources/ then + Gem.sources.to_a.join("\n") + when /^platform/ then + Gem.platforms.join(File::PATH_SEPARATOR) + when /^credentials/, /^creds/ then + Gem.configuration.credentials_path + when nil then + show_environment + else + raise Gem::CommandLineError, "Unknown environment option [#{arg}]" + end + say out + true + end + + def add_path(out, path) + path.each do |component| + out << " - #{component}\n" + end + end + + def show_environment # :nodoc: + out = "RubyGems Environment:\n".dup + + out << " - RUBYGEMS VERSION: #{Gem::VERSION}\n" + + out << " - RUBY VERSION: #{RUBY_VERSION} (#{RUBY_RELEASE_DATE} patchlevel #{RUBY_PATCHLEVEL}) [#{RUBY_PLATFORM}]\n" + + out << " - INSTALLATION DIRECTORY: #{Gem.dir}\n" + + out << " - USER INSTALLATION DIRECTORY: #{Gem.user_dir}\n" + + out << " - CREDENTIALS FILE: #{Gem.configuration.credentials_path}\n" + + out << " - RUBYGEMS PREFIX: #{Gem.prefix}\n" unless Gem.prefix.nil? + + out << " - RUBY EXECUTABLE: #{Gem.ruby}\n" + + out << " - GIT EXECUTABLE: #{git_path}\n" + + out << " - EXECUTABLE DIRECTORY: #{Gem.bindir}\n" + + out << " - SPEC CACHE DIRECTORY: #{Gem.spec_cache_dir}\n" + + out << " - SYSTEM CONFIGURATION DIRECTORY: #{Gem::ConfigFile::SYSTEM_CONFIG_PATH}\n" + + out << " - RUBYGEMS PLATFORMS:\n" + Gem.platforms.each do |platform| + out << " - #{platform}\n" + end + + out << " - GEM PATHS:\n" + out << " - #{Gem.dir}\n" + + gem_path = Gem.path.dup + gem_path.delete Gem.dir + add_path out, gem_path + + out << " - GEM CONFIGURATION:\n" + Gem.configuration.each do |name, value| + value = value.gsub(/./, "*") if name == "gemcutter_key" + out << " - #{name.inspect} => #{value.inspect}\n" + end + + out << " - REMOTE SOURCES:\n" + Gem.sources.each do |s| + out << " - #{s}\n" + end + + out << " - SHELL PATH:\n" + + shell_path = ENV["PATH"].split(File::PATH_SEPARATOR) + add_path out, shell_path + + out + end + + private + + ## + # Git binary path + + def git_path + exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""] + ENV["PATH"].split(File::PATH_SEPARATOR).each do |path| + exts.each do |ext| + exe = File.join(path, "git#{ext}") + return exe if File.executable?(exe) && !File.directory?(exe) + end + end + + nil + end +end diff --git a/lib/rubygems/commands/exec_command.rb b/lib/rubygems/commands/exec_command.rb new file mode 100644 index 0000000000..1feafbdd35 --- /dev/null +++ b/lib/rubygems/commands/exec_command.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../dependency_installer" +require_relative "../gem_runner" +require_relative "../package" +require_relative "../version_option" + +class Gem::Commands::ExecCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "exec", "Run a command from a gem", { + version: Gem::Requirement.default, + } + + add_version_option + add_prerelease_option "to be installed" + + add_option "-g", "--gem GEM", "run the executable from the given gem" do |value, options| + options[:gem_name] = value + end + + add_option(:"Install/Update", "--conservative", + "Prefer the most recent installed version, ", + "rather than the latest version overall") do |_value, options| + options[:conservative] = true + end + end + + def arguments # :nodoc: + "COMMAND the executable command to run" + end + + def defaults_str # :nodoc: + "--version '#{Gem::Requirement.default}'" + end + + def description # :nodoc: + <<-EOF +The exec command handles installing (if necessary) and running an executable +from a gem, regardless of whether that gem is currently installed. + +The exec command can be thought of as a shortcut to running `gem install` and +then the executable from the installed gem. + +For example, `gem exec rails new .` will run `rails new .` in the current +directory, without having to manually run `gem install rails`. +Additionally, the exec command ensures the most recent version of the gem +is used (unless run with `--conservative`), and that the gem is not installed +to the same gem path as user-installed gems. + EOF + end + + def usage # :nodoc: + "#{program_name} [options --] COMMAND [args]" + end + + def execute + check_executable + + print_command + if options[:gem_name] == "gem" && options[:executable] == "gem" + set_gem_exec_install_paths + Gem::GemRunner.new.run options[:args] + return + elsif options[:conservative] + install_if_needed + else + install + activate! + end + + load! + end + + private + + def handle_options(args) + args = add_extra_args(args) + check_deprecated_options(args) + @options = Marshal.load Marshal.dump @defaults # deep copy + parser.order!(args) do |v| + # put the non-option back at the front of the list of arguments + args.unshift(v) + + # stop parsing once we hit the first non-option, + # so you can call `gem exec rails --version` and it prints the rails + # version rather than rubygem's + break + end + @options[:args] = args + + options[:executable], gem_version = extract_gem_name_and_version(options[:args].shift) + options[:gem_name] ||= options[:executable] + + if gem_version + if options[:version].none? + options[:version] = Gem::Requirement.new(gem_version) + else + options[:version].concat [gem_version] + end + end + + if options[:prerelease] && !options[:version].prerelease? + if options[:version].none? + options[:version] = Gem::Requirement.default_prerelease + else + options[:version].concat [Gem::Requirement.default_prerelease] + end + end + end + + def check_executable + if options[:executable].nil? + raise Gem::CommandLineError, + "Please specify an executable to run (e.g. #{program_name} COMMAND)" + end + end + + def print_command + verbose "running #{program_name} with:\n" + opts = options.reject {|_, v| v.nil? || Array(v).empty? } + max_length = opts.map {|k, _| k.size }.max + opts.each do |k, v| + next if v.nil? + verbose "\t#{k.to_s.rjust(max_length)}: #{v}" + end + verbose "" + end + + def install_if_needed + activate! + rescue Gem::MissingSpecError + verbose "#{Gem::Dependency.new(options[:gem_name], options[:version])} not available locally, installing from remote" + install + activate! + end + + def set_gem_exec_install_paths + home = Gem.dir + + ENV["GEM_PATH"] = ([home] + Gem.path).join(File::PATH_SEPARATOR) + ENV["GEM_HOME"] = home + Gem.clear_paths + end + + def install + set_gem_exec_install_paths + + gem_name = options[:gem_name] + gem_version = options[:version] + + install_options = options.merge( + minimal_deps: false, + wrappers: true + ) + + suppress_always_install do + dep_installer = Gem::DependencyInstaller.new install_options + + request_set = dep_installer.resolve_dependencies gem_name, gem_version + + verbose "Gems to install:" + request_set.sorted_requests.each do |activation_request| + verbose "\t#{activation_request.full_name}" + end + + request_set.install install_options + end + + Gem::Specification.reset + rescue Gem::InstallError => e + alert_error "Error installing #{gem_name}:\n\t#{e.message}" + terminate_interaction 1 + rescue Gem::DependencyResolutionError => e + alert_error "Error installing #{gem_name}:\n\t#{e.message}" + terminate_interaction 2 + rescue Gem::GemNotFoundException => e + show_lookup_failure e.name, e.version, e.errors, false + + terminate_interaction 2 + rescue Gem::UnsatisfiableDependencyError => e + show_lookup_failure e.name, e.version, e.errors, false, + "'#{gem_name}' (#{gem_version})" + + terminate_interaction 2 + end + + def activate! + gem(options[:gem_name], options[:version]) + Gem.finish_resolve + + verbose "activated #{options[:gem_name]} (#{Gem.loaded_specs[options[:gem_name]].version})" + end + + def load! + argv = ARGV.clone + ARGV.replace options[:args] + + executable = options[:executable] + + contains_executable = Gem.loaded_specs.values.select do |spec| + spec.executables.include?(executable) + end + + if contains_executable.any? {|s| s.name == executable } + contains_executable.select! {|s| s.name == executable } + end + + if contains_executable.empty? + spec = Gem.loaded_specs[executable] + + if spec.nil? || spec.executables.empty? + alert_error "Failed to load executable `#{executable}`," \ + " are you sure the gem `#{options[:gem_name]}` contains it?" + terminate_interaction 1 + end + + if spec.executables.size > 1 + alert_error "Ambiguous which executable from gem `#{executable}` should be run: " \ + "the options are #{spec.executables.sort}, specify one via COMMAND, and use `-g` and `-v` to specify gem and version" + terminate_interaction 1 + end + + contains_executable << spec + executable = spec.executable + end + + if contains_executable.size > 1 + alert_error "Ambiguous which gem `#{executable}` should come from: " \ + "the options are #{contains_executable.map(&:name)}, " \ + "specify one via `-g`" + terminate_interaction 1 + end + + old_exe = $0 + $0 = executable + load Gem.activate_bin_path(contains_executable.first.name, executable, ">= 0.a") + ensure + $0 = old_exe if old_exe + ARGV.replace argv + end + + def suppress_always_install + name = :always_install + cls = ::Gem::Resolver::InstallerSet + method = cls.instance_method(name) + cls.remove_method(name) + cls.define_method(name) { [] } + + begin + yield + ensure + cls.remove_method(name) + cls.define_method(name, method) + end + end +end diff --git a/lib/rubygems/commands/fetch_command.rb b/lib/rubygems/commands/fetch_command.rb new file mode 100644 index 0000000000..8e64a18cee --- /dev/null +++ b/lib/rubygems/commands/fetch_command.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../version_option" + +class Gem::Commands::FetchCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::VersionOption + + def initialize + defaults = { + suggest_alternate: true, + version: Gem::Requirement.default, + } + + super "fetch", "Download a gem and place it in the current directory", defaults + + add_bulk_threshold_option + add_proxy_option + add_source_option + add_clear_sources_option + + add_version_option + add_platform_option + add_prerelease_option + + add_option "--[no-]suggestions", "Suggest alternates when gems are not found" do |value, options| + options[:suggest_alternate] = value + end + end + + def arguments # :nodoc: + "GEMNAME name of gem to download" + end + + def defaults_str # :nodoc: + "--version '#{Gem::Requirement.default}'" + end + + def description # :nodoc: + <<-EOF +The fetch command fetches gem files that can be stored for later use or +unpacked to examine their contents. + +See the build command help for an example of unpacking a gem, modifying it, +then repackaging it. + EOF + end + + def usage # :nodoc: + "#{program_name} GEMNAME [GEMNAME ...]" + end + + def check_version # :nodoc: + if options[:version] != Gem::Requirement.default && + get_all_gem_names.size > 1 + alert_error "Can't use --version with multiple gems. You can specify multiple gems with" \ + " version requirements using `gem fetch 'my_gem:1.0.0' 'my_other_gem:>=2'`" + terminate_interaction 1 + end + end + + def execute + check_version + + exit_code = fetch_gems + + terminate_interaction exit_code + end + + private + + def fetch_gems + exit_code = 0 + + version = options[:version] + + platform = Gem.platforms.last + gem_names = get_all_gem_names_and_versions + + gem_names.each do |gem_name, gem_version| + gem_version ||= version + dep = Gem::Dependency.new gem_name, gem_version + dep.prerelease = options[:prerelease] + suppress_suggestions = !options[:suggest_alternate] + + specs_and_sources, errors = + Gem::SpecFetcher.fetcher.spec_for_dependency dep + + if platform + filtered = specs_and_sources.select {|s,| s.platform == platform } + specs_and_sources = filtered unless filtered.empty? + end + + spec, source = specs_and_sources.max_by {|s,| s } + + if spec.nil? + show_lookup_failure gem_name, gem_version, errors, suppress_suggestions, options[:domain] + exit_code |= 2 + next + end + source.download spec + say "Downloaded #{spec.full_name}" + end + + exit_code + end +end diff --git a/lib/rubygems/commands/generate_index_command.rb b/lib/rubygems/commands/generate_index_command.rb new file mode 100644 index 0000000000..13be92593b --- /dev/null +++ b/lib/rubygems/commands/generate_index_command.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../command" + +unless defined? Gem::Commands::GenerateIndexCommand + class Gem::Commands::GenerateIndexCommand < Gem::Command + module RubygemsTrampoline + def description # :nodoc: + <<~EOF + The generate_index command has been moved to the rubygems-generate_index gem. + EOF + end + + def execute + alert_error "Install the rubygems-generate_index gem for the generate_index command" + end + + def invoke_with_build_args(args, build_args) + name = "rubygems-generate_index" + spec = begin + Gem::Specification.find_by_name(name) + rescue Gem::LoadError + require "rubygems/dependency_installer" + Gem.install(name, Gem::Requirement.default, Gem::DependencyInstaller::DEFAULT_OPTIONS).find {|s| s.name == name } + end + + # remove the methods defined in this file so that the methods defined in the gem are used instead, + # and without a method redefinition warning + %w[description execute invoke_with_build_args].each do |method| + RubygemsTrampoline.remove_method(method) + end + self.class.singleton_class.remove_method(:new) + + spec.activate + Gem.load_plugin_files spec.matches_for_glob("rubygems_plugin#{Gem.suffix_pattern}") + + self.class.new.invoke_with_build_args(args, build_args) + end + end + private_constant :RubygemsTrampoline + + # remove_method(:initialize) warns, but removing new does not warn + def self.new + command = allocate + command.send(:initialize, "generate_index", "Generates the index files for a gem server directory (requires rubygems-generate_index)") + command + end + + prepend(RubygemsTrampoline) + end +end diff --git a/lib/rubygems/commands/help_command.rb b/lib/rubygems/commands/help_command.rb new file mode 100644 index 0000000000..664f400561 --- /dev/null +++ b/lib/rubygems/commands/help_command.rb @@ -0,0 +1,377 @@ +# frozen_string_literal: true + +require_relative "../command" + +class Gem::Commands::HelpCommand < Gem::Command + # :stopdoc: + EXAMPLES = <<-EOF +Some examples of 'gem' usage. + +* Install 'rake', either from local directory or remote server: + + gem install rake + +* Install 'rake', only from remote server: + + gem install rake --remote + +* Install 'rake', but only version 0.3.1, even if dependencies + are not met, and into a user-specific directory: + + gem install rake --version 0.3.1 --force --user-install + +* List local gems whose name begins with 'D': + + gem list D + +* List local and remote gems whose name contains 'log': + + gem search log --both + +* List only remote gems whose name contains 'log': + + gem search log --remote + +* Uninstall 'rake': + + gem uninstall rake + +* Create a gem: + + See https://guides.rubygems.org/make-your-own-gem/ + +* See information about RubyGems: + + gem environment + +* Update all gems on your system: + + gem update + +* Update your local version of RubyGems + + gem update --system + EOF + + GEM_DEPENDENCIES = <<-EOF +A gem dependencies file allows installation of a consistent set of gems across +multiple environments. The RubyGems implementation is designed to be +compatible with Bundler's Gemfile format. You can see additional +documentation on the format at: + + https://bundler.io + +RubyGems automatically looks for these gem dependencies files: + +* gem.deps.rb +* Gemfile +* Isolate + +These files are looked up automatically using `gem install -g`, or you can +specify a custom file. + +When the RUBYGEMS_GEMDEPS environment variable is set to a gem dependencies +file the gems from that file will be activated at startup time. Set it to a +specific filename or to "-" to have RubyGems automatically discover the gem +dependencies file by walking up from the current directory. + +You can also activate gem dependencies at program startup using +Gem.use_gemdeps. + +NOTE: Enabling automatic discovery on multiuser systems can lead to execution +of arbitrary code when used from directories outside your control. + +Gem Dependencies +================ + +Use #gem to declare which gems you directly depend upon: + + gem 'rake' + +To depend on a specific set of versions: + + gem 'rake', '>= 10.3.2' + # or for multiple version restrictions + gem 'rake', '>= 10.3.2', "< 13" + +RubyGems will require the gem name when activating the gem using +the RUBYGEMS_GEMDEPS environment variable or Gem::use_gemdeps. Use the +require: option to override this behavior if the gem does not have a file of +that name or you don't want to require those files: + + gem 'my_gem', require: 'other_file' + +To prevent RubyGems from requiring any files use: + + gem 'my_gem', require: false + +To load dependencies from a .gemspec file: + + gemspec + +RubyGems looks for the first .gemspec file in the current directory. To +override this use the name: option: + + gemspec name: 'specific_gem' + +To look in a different directory use the path: option: + + gemspec name: 'specific_gem', path: 'gemspecs' + +To depend on a gem unpacked into a local directory: + + gem 'modified_gem', path: 'vendor/modified_gem' + +To depend on a gem from git: + + gem 'private_gem', git: 'git@my.company.example:private_gem.git' + +To depend on a gem from github: + + gem 'private_gem', github: 'my_company/private_gem' + +To depend on a gem from a github gist: + + gem 'bang', gist: '1232884' + +Git, github and gist support the ref:, branch: and tag: options to specify a +commit reference or hash, branch or tag respectively to use for the gem. + +Setting the submodules: option to true for git, github and gist dependencies +causes fetching of submodules when fetching the repository. + +You can depend on multiple gems from a single repository with the git method: + + git 'https://github.com/rails/rails.git' do + gem 'activesupport' + gem 'activerecord' + end + +Gem Sources +=========== + +RubyGems uses the default sources for regular `gem install` for gem +dependencies files. Unlike bundler, you do need to specify a source. + +You can override the sources used for downloading gems with: + + source 'https://gem_server.example' + +You may specify multiple sources. Unlike bundler the prepend: option is not +supported. Sources are used in-order, to prepend a source place it at the +front of the list. + +Gem Platform +============ + +You can restrict gem dependencies to specific platforms with the #platform +and #platforms methods: + + platform :ruby_21 do + gem 'debugger' + end + +See the bundler Gemfile manual page for a list of platforms supported in a gem +dependencies file.: + + https://bundler.io/v2.5/man/gemfile.5.html + +Ruby Version and Engine Dependency +================================== + +You can specify the version, engine and engine version of ruby to use with +your gem dependencies file. If you are not running the specified version +RubyGems will raise an exception. + +To depend on a specific version of ruby: + + ruby '2.1.2' + +To depend on a specific ruby engine: + + ruby '1.9.3', engine: 'jruby' + +To depend on a specific ruby engine version: + + ruby '1.9.3', engine: 'jruby', engine_version: '1.7.11' + +Grouping Dependencies +===================== + +Gem dependencies may be placed in groups that can be excluded from install. +Dependencies required for development or testing of your code may be excluded +when installed in a production environment. + +A #gem dependency may be placed in a group using the group: option: + + gem 'minitest', group: :test + +To install dependencies from a gemfile without specific groups use the +`--without` option for `gem install -g`: + + $ gem install -g --without test + +The group: option also accepts multiple groups if the gem fits in multiple +categories. + +Multiple groups may be excluded during install by comma-separating the groups for `--without` or by specifying `--without` multiple times. + +The #group method can also be used to place gems in groups: + + group :test do + gem 'minitest' + gem 'minitest-emoji' + end + +The #group method allows multiple groups. + +The #gemspec development dependencies are placed in the :development group by +default. This may be overridden with the :development_group option: + + gemspec development_group: :other + + EOF + + PLATFORMS = <<-'EOF' +RubyGems platforms are composed of three parts, a CPU, an OS, and a +version. These values are taken from values in rbconfig.rb. You can view +your current platform by running `gem environment`. + +RubyGems matches platforms as follows: + + * The CPU must match exactly unless one of the platforms has + "universal" as the CPU or the local CPU starts with "arm" and the gem's + CPU is exactly "arm" (for gems that support generic ARM architecture). + * The OS must match exactly. + * The versions must match exactly unless one of the versions is nil. + +For commands that install, uninstall and list gems, you can override what +RubyGems thinks your platform is with the --platform option. The platform +you pass must match "#{cpu}-#{os}" or "#{cpu}-#{os}-#{version}". On mswin +platforms, the version is the compiler version, not the OS version. (Ruby +compiled with VC6 uses "60" as the compiler version, VC8 uses "80".) + +For the ARM architecture, gems with a platform of "arm-linux" should run on a +reasonable set of ARM CPUs and not depend on instructions present on a limited +subset of the architecture. For example, the binary should run on platforms +armv5, armv6hf, armv6l, armv7, etc. If you use the "arm-linux" platform +please test your gem on a variety of ARM hardware before release to ensure it +functions correctly. + +Example platforms: + + x86-freebsd # Any FreeBSD version on an x86 CPU + universal-darwin-8 # Darwin 8 only gems that run on any CPU + x86-mswin32-80 # Windows gems compiled with VC8 + armv7-linux # Gem complied for an ARMv7 CPU running linux + arm-linux # Gem compiled for any ARM CPU running linux + +When building platform gems, set the platform in the gem specification to +Gem::Platform::CURRENT. This will correctly mark the gem with your ruby's +platform. + EOF + + # NOTE: when updating also update Gem::Command::HELP + + SUBCOMMANDS = [ + ["commands", :show_commands], + ["options", Gem::Command::HELP], + ["examples", EXAMPLES], + ["gem_dependencies", GEM_DEPENDENCIES], + ["platforms", PLATFORMS], + ].freeze + # :startdoc: + + def initialize + super "help", "Provide help on the 'gem' command" + + @command_manager = Gem::CommandManager.instance + end + + def usage # :nodoc: + "#{program_name} ARGUMENT" + end + + def execute + arg = options[:args][0] + + _, help = SUBCOMMANDS.find do |command,| + begins? command, arg + end + + if help + if Symbol === help + send help + else + say help + end + return + end + + if options[:help] + show_help + + elsif arg + show_command_help arg + + else + say Gem::Command::HELP + end + end + + def show_commands # :nodoc: + out = [] + out << "GEM commands are:" + out << nil + + margin_width = 4 + + desc_width = @command_manager.command_names.map(&:size).max + 4 + + summary_width = 80 - margin_width - desc_width + wrap_indent = " " * (margin_width + desc_width) + format = "#{" " * margin_width}%-#{desc_width}s%s" + + @command_manager.command_names.each do |cmd_name| + command = @command_manager[cmd_name] + + next if command&.deprecated? + + summary = + if command + command.summary + else + "[No command found for #{cmd_name}]" + end + + summary = wrap(summary, summary_width).split "\n" + out << format(format, cmd_name, summary.shift) + until summary.empty? do + out << "#{wrap_indent}#{summary.shift}" + end + end + + out << nil + out << "For help on a particular command, use 'gem help COMMAND'." + out << nil + out << "Commands may be abbreviated, so long as they are unambiguous." + out << "e.g. 'gem i rake' is short for 'gem install rake'." + + say out.join("\n") + end + + def show_command_help(command_name) # :nodoc: + command_name = command_name.downcase + + possibilities = @command_manager.find_command_possibilities command_name + + if possibilities.size == 1 + command = @command_manager[possibilities.first] + command.invoke("--help") + elsif possibilities.size > 1 + alert_warning "Ambiguous command #{command_name} (#{possibilities.join(", ")})" + else + alert_warning "Unknown command #{command_name}. Try: gem help commands" + end + end +end diff --git a/lib/rubygems/commands/info_command.rb b/lib/rubygems/commands/info_command.rb new file mode 100644 index 0000000000..f65c639662 --- /dev/null +++ b/lib/rubygems/commands/info_command.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../query_utils" + +class Gem::Commands::InfoCommand < Gem::Command + include Gem::QueryUtils + + def initialize + super "info", "Show information for the given gem", + name: //, domain: :local, details: false, versions: true, + installed: nil, version: Gem::Requirement.default + + add_query_options + + remove_option("-d") + + defaults[:details] = true + defaults[:exact] = true + end + + def description # :nodoc: + "Info prints information about the gem such as name,"\ + " description, website, license and installed paths" + end + + def usage # :nodoc: + "#{program_name} GEMNAME" + end + + def arguments # :nodoc: + "GEMNAME name of the gem to print information about" + end + + def defaults_str + "--local" + end +end diff --git a/lib/rubygems/commands/install_command.rb b/lib/rubygems/commands/install_command.rb new file mode 100644 index 0000000000..6d3beec0b4 --- /dev/null +++ b/lib/rubygems/commands/install_command.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../install_update_options" +require_relative "../dependency_installer" +require_relative "../local_remote_options" +require_relative "../validator" +require_relative "../version_option" +require_relative "../update_suggestion" + +## +# Gem installer command line tool +# +# See `gem help install` + +class Gem::Commands::InstallCommand < Gem::Command + attr_reader :installed_specs # :nodoc: + + include Gem::VersionOption + include Gem::LocalRemoteOptions + include Gem::InstallUpdateOptions + include Gem::UpdateSuggestion + + def initialize + defaults = Gem::DependencyInstaller::DEFAULT_OPTIONS.merge({ + format_executable: false, + lock: true, + suggest_alternate: true, + version: Gem::Requirement.default, + without_groups: [], + }) + + defaults.merge!(install_update_options) + + super "install", "Install a gem into the local repository", defaults + + add_install_update_options + add_local_remote_options + add_platform_option + add_version_option + add_prerelease_option "to be installed. (Only for listed gems)" + + @installed_specs = [] + end + + def arguments # :nodoc: + "GEMNAME name of gem to install" + end + + def defaults_str # :nodoc: + "--both --version '#{Gem::Requirement.default}' --no-force\n" \ + "--install-dir #{Gem.dir} --lock\n" + + install_update_defaults_str + end + + def description # :nodoc: + <<-EOF +The install command installs local or remote gem into a gem repository. + +For gems with executables ruby installs a wrapper file into the executable +directory by default. This can be overridden with the --no-wrappers option. +The wrapper allows you to choose among alternate gem versions using _version_. + +For example `rake _0.7.3_ --version` will run rake version 0.7.3 if a newer +version is also installed. + +Gem Dependency Files +==================== + +RubyGems can install a consistent set of gems across multiple environments +using `gem install -g` when a gem dependencies file (gem.deps.rb, Gemfile or +Isolate) is present. If no explicit file is given RubyGems attempts to find +one in the current directory. + +When the RUBYGEMS_GEMDEPS environment variable is set to a gem dependencies +file the gems from that file will be activated at startup time. Set it to a +specific filename or to "-" to have RubyGems automatically discover the gem +dependencies file by walking up from the current directory. + +NOTE: Enabling automatic discovery on multiuser systems can lead to +execution of arbitrary code when used from directories outside your control. + +Extension Install Failures +========================== + +If an extension fails to compile during gem installation the gem +specification is not written out, but the gem remains unpacked in the +repository. You may need to specify the path to the library's headers and +libraries to continue. You can do this by adding a -- between RubyGems' +options and the extension's build options: + + $ gem install some_extension_gem + [build fails] + Gem files will remain installed in \\ + /path/to/gems/some_extension_gem-1.0 for inspection. + Results logged to /path/to/gems/some_extension_gem-1.0/gem_make.out + $ gem install some_extension_gem -- --with-extension-lib=/path/to/lib + [build succeeds] + $ gem list some_extension_gem + + *** LOCAL GEMS *** + + some_extension_gem (1.0) + $ + +If you correct the compilation errors by editing the gem files you will need +to write the specification by hand. For example: + + $ gem install some_extension_gem + [build fails] + Gem files will remain installed in \\ + /path/to/gems/some_extension_gem-1.0 for inspection. + Results logged to /path/to/gems/some_extension_gem-1.0/gem_make.out + $ [cd /path/to/gems/some_extension_gem-1.0] + $ [edit files or what-have-you and run make] + $ gem spec ../../cache/some_extension_gem-1.0.gem --ruby > \\ + ../../specifications/some_extension_gem-1.0.gemspec + $ gem list some_extension_gem + + *** LOCAL GEMS *** + + some_extension_gem (1.0) + $ + +Command Alias +========================== + +You can use `i` command instead of `install`. + + $ gem i GEMNAME + + EOF + end + + def usage # :nodoc: + "#{program_name} [options] GEMNAME [GEMNAME ...] -- --build-flags" + end + + def check_version # :nodoc: + if options[:version] != Gem::Requirement.default && + get_all_gem_names.size > 1 + alert_error "Can't use --version with multiple gems. You can specify multiple gems with" \ + " version requirements using `gem install 'my_gem:1.0.0' 'my_other_gem:>=2'`" + terminate_interaction 1 + end + end + + def execute + if options.include? :gemdeps + install_from_gemdeps + return # not reached + end + + @installed_specs = [] + + ENV.delete "GEM_PATH" if options[:install_dir].nil? + + check_version + + load_hooks + + exit_code = install_gems + + show_installed + + say update_suggestion if eligible_for_update? + + terminate_interaction exit_code + end + + def install_from_gemdeps # :nodoc: + require_relative "../request_set" + rs = Gem::RequestSet.new + + specs = rs.install_from_gemdeps options do |req, inst| + s = req.full_spec + + if inst + say "Installing #{s.name} (#{s.version})" + else + say "Using #{s.name} (#{s.version})" + end + end + + @installed_specs = specs + + terminate_interaction + end + + def install_gem(name, version) # :nodoc: + return if options[:conservative] && + !Gem::Dependency.new(name, version).matching_specs.empty? + + req = Gem::Requirement.create(version) + + dinst = Gem::DependencyInstaller.new options + + request_set = dinst.resolve_dependencies name, req + + if options[:explain] + say "Gems to install:" + + request_set.sorted_requests.each do |activation_request| + say " #{activation_request.full_name}" + end + else + @installed_specs.concat request_set.install options + end + + show_install_errors dinst.errors + end + + def install_gems # :nodoc: + exit_code = 0 + + get_all_gem_names_and_versions.each do |gem_name, gem_version| + gem_version ||= options[:version] + domain = options[:domain] + domain = :local unless options[:suggest_alternate] + suppress_suggestions = (domain == :local) + + begin + install_gem gem_name, gem_version + rescue Gem::InstallError => e + alert_error "Error installing #{gem_name}:\n\t#{e.message}" + exit_code |= 1 + rescue Gem::DependencyResolutionError => e + alert_error "Error installing #{gem_name}:\n\t#{e.message}" + exit_code |= 2 + rescue Gem::UnsatisfiableDependencyError => e + show_lookup_failure e.name, e.version, e.errors, suppress_suggestions, + "'#{gem_name}' (#{gem_version})" + + exit_code |= 2 + end + end + + exit_code + end + + ## + # Loads post-install hooks + + def load_hooks # :nodoc: + require_relative "../install_message" + require_relative "../rdoc" + end + + def show_install_errors(errors) # :nodoc: + return unless errors + + errors.each do |x| + next unless Gem::SourceFetchProblem === x + + require_relative "../uri" + msg = "Unable to pull data from '#{Gem::Uri.redact(x.source.uri)}': #{x.error.message}" + + alert_warning msg + end + end + + def show_installed # :nodoc: + return if @installed_specs.empty? + + gems = @installed_specs.length == 1 ? "gem" : "gems" + say "#{@installed_specs.length} #{gems} installed" + end +end diff --git a/lib/rubygems/commands/list_command.rb b/lib/rubygems/commands/list_command.rb new file mode 100644 index 0000000000..fab4b73814 --- /dev/null +++ b/lib/rubygems/commands/list_command.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../query_utils" + +## +# Searches for gems starting with the supplied argument. + +class Gem::Commands::ListCommand < Gem::Command + include Gem::QueryUtils + + def initialize + super "list", "Display local gems whose name matches REGEXP", + domain: :local, details: false, versions: true, + installed: nil, version: Gem::Requirement.default + + add_query_options + end + + def arguments # :nodoc: + "REGEXP regexp to look for in gem name" + end + + def defaults_str # :nodoc: + "--local --no-details" + end + + def description # :nodoc: + <<-EOF +The list command is used to view the gems you have installed locally. + +The --details option displays additional details including the summary, the +homepage, the author, the locations of different versions of the gem. + +To search for remote gems use the search command. + EOF + end + + def usage # :nodoc: + "#{program_name} [REGEXP ...]" + end +end diff --git a/lib/rubygems/commands/lock_command.rb b/lib/rubygems/commands/lock_command.rb new file mode 100644 index 0000000000..f7fd5ada16 --- /dev/null +++ b/lib/rubygems/commands/lock_command.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative "../command" + +class Gem::Commands::LockCommand < Gem::Command + def initialize + super "lock", "Generate a lockdown list of gems", + strict: false + + add_option "-s", "--[no-]strict", + "fail if unable to satisfy a dependency" do |strict, options| + options[:strict] = strict + end + end + + def arguments # :nodoc: + "GEMNAME name of gem to lock\nVERSION version of gem to lock" + end + + def defaults_str # :nodoc: + "--no-strict" + end + + def description # :nodoc: + <<-EOF +The lock command will generate a list of +gem+ statements that will lock down +the versions for the gem given in the command line. It will specify exact +versions in the requirements list to ensure that the gems loaded will always +be consistent. A full recursive search of all effected gems will be +generated. + +Example: + + gem lock rails-1.0.0 > lockdown.rb + +will produce in lockdown.rb: + + require "rubygems" + gem 'rails', '= 1.0.0' + gem 'rake', '= 0.7.0.1' + gem 'activesupport', '= 1.2.5' + gem 'activerecord', '= 1.13.2' + gem 'actionpack', '= 1.11.2' + gem 'actionmailer', '= 1.1.5' + gem 'actionwebservice', '= 1.0.0' + +Just load lockdown.rb from your application to ensure that the current +versions are loaded. Make sure that lockdown.rb is loaded *before* any +other require statements. + +Notice that rails 1.0.0 only requires that rake 0.6.2 or better be used. +Rake-0.7.0.1 is the most recent version installed that satisfies that, so we +lock it down to the exact version. + EOF + end + + def usage # :nodoc: + "#{program_name} GEMNAME-VERSION [GEMNAME-VERSION ...]" + end + + def complain(message) + if options[:strict] + raise Gem::Exception, message + else + say "# #{message}" + end + end + + def execute + say "require 'rubygems'" + + locked = {} + + pending = options[:args] + + until pending.empty? do + full_name = pending.shift + + spec = Gem::Specification.load spec_path(full_name) + + if spec.nil? + complain "Could not find gem #{full_name}, try using the full name" + next + end + + say "gem '#{spec.name}', '= #{spec.version}'" unless locked[spec.name] + locked[spec.name] = true + + spec.runtime_dependencies.each do |dep| + next if locked[dep.name] + candidates = dep.matching_specs + + if candidates.empty? + complain "Unable to satisfy '#{dep}' from currently installed gems" + else + pending << candidates.last.full_name + end + end + end + end + + def spec_path(gem_full_name) + gemspecs = Gem.path.map do |path| + File.join path, "specifications", "#{gem_full_name}.gemspec" + end + + gemspecs.find {|path| File.exist? path } + end +end diff --git a/lib/rubygems/commands/mirror_command.rb b/lib/rubygems/commands/mirror_command.rb new file mode 100644 index 0000000000..b91a8db12d --- /dev/null +++ b/lib/rubygems/commands/mirror_command.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../command" + +unless defined? Gem::Commands::MirrorCommand + class Gem::Commands::MirrorCommand < Gem::Command + def initialize + super("mirror", "Mirror all gem files (requires rubygems-mirror)") + begin + Gem::Specification.find_by_name("rubygems-mirror").activate + rescue Gem::LoadError + # no-op + end + end + + def description # :nodoc: + <<-EOF +The mirror command has been moved to the rubygems-mirror gem. + EOF + end + + def execute + alert_error "Install the rubygems-mirror gem for the mirror command" + end + end +end diff --git a/lib/rubygems/commands/open_command.rb b/lib/rubygems/commands/open_command.rb new file mode 100644 index 0000000000..0fe90dc8b8 --- /dev/null +++ b/lib/rubygems/commands/open_command.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../version_option" + +class Gem::Commands::OpenCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "open", "Open gem sources in editor" + + add_option("-e", "--editor COMMAND", String, + "Prepends COMMAND to gem path. Could be used to specify editor.") do |command, options| + options[:editor] = command || get_env_editor + end + add_option("-v", "--version VERSION", String, + "Opens specific gem version") do |version| + options[:version] = version + end + end + + def arguments # :nodoc: + "GEMNAME name of gem to open in editor" + end + + def defaults_str # :nodoc: + "-e #{get_env_editor}" + end + + def description # :nodoc: + <<-EOF + The open command opens gem in editor and changes current path + to gem's source directory. + Editor command can be specified with -e option, otherwise rubygems + will look for editor in $EDITOR, $VISUAL and $GEM_EDITOR variables. + EOF + end + + def usage # :nodoc: + "#{program_name} [-e COMMAND] GEMNAME" + end + + def get_env_editor + ENV["GEM_EDITOR"] || + ENV["VISUAL"] || + ENV["EDITOR"] || + "vi" + end + + def execute + @version = options[:version] || Gem::Requirement.default + @editor = options[:editor] || get_env_editor + + found = open_gem(get_one_gem_name) + + terminate_interaction 1 unless found + end + + def open_gem(name) + spec = spec_for name + + return false unless spec + + if spec.default_gem? + say "'#{name}' is a default gem and can't be opened." + return false + end + + open_editor(spec.full_gem_path) + end + + def open_editor(path) + system(*@editor.split(/\s+/) + [path], { chdir: path }) + end + + def spec_for(name) + spec = Gem::Specification.find_all_by_name(name, @version).first + + return spec if spec + + say "Unable to find gem '#{name}'" + end +end diff --git a/lib/rubygems/commands/outdated_command.rb b/lib/rubygems/commands/outdated_command.rb new file mode 100644 index 0000000000..08a9221a26 --- /dev/null +++ b/lib/rubygems/commands/outdated_command.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../spec_fetcher" +require_relative "../version_option" + +class Gem::Commands::OutdatedCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::VersionOption + + def initialize + super "outdated", "Display all gems that need updates" + + add_local_remote_options + add_platform_option + end + + def description # :nodoc: + <<-EOF +The outdated command lists gems you may wish to upgrade to a newer version. + +You can check for dependency mismatches using the dependency command and +update the gems with the update or install commands. + EOF + end + + def execute + Gem::Specification.outdated_and_latest_version.each do |spec, remote_version| + say "#{spec.name} (#{spec.version} < #{remote_version})" + end + end +end diff --git a/lib/rubygems/commands/owner_command.rb b/lib/rubygems/commands/owner_command.rb new file mode 100644 index 0000000000..675e866734 --- /dev/null +++ b/lib/rubygems/commands/owner_command.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../gemcutter_utilities" +require_relative "../text" + +class Gem::Commands::OwnerCommand < Gem::Command + include Gem::Text + include Gem::LocalRemoteOptions + include Gem::GemcutterUtilities + + def description # :nodoc: + <<-EOF +The owner command lets you add and remove owners of a gem on a push +server (the default is https://rubygems.org). Multiple owners can be +added or removed at the same time, if the flag is given multiple times. + +The supported user identifiers are dependent on the push server. +For rubygems.org, both e-mail and handle are supported, even though the +user identifier field is called "email". + +The owner of a gem has the permission to push new versions, yank existing +versions or edit the HTML page of the gem. Be careful of who you give push +permission to. + EOF + end + + def arguments # :nodoc: + "GEM gem to manage owners for" + end + + def usage # :nodoc: + "#{program_name} GEM" + end + + def initialize + super "owner", "Manage gem owners of a gem on the push server" + add_proxy_option + add_key_option + add_otp_option + defaults.merge! add: [], remove: [] + + add_option "-a", "--add NEW_OWNER", "Add an owner by user identifier" do |value, options| + options[:add] << value + end + + add_option "-r", "--remove OLD_OWNER", "Remove an owner by user identifier" do |value, options| + options[:remove] << value + end + + add_option "-h", "--host HOST", + "Use another gemcutter-compatible host", + " (e.g. https://rubygems.org)" do |value, options| + options[:host] = value + end + end + + def execute + @host = options[:host] + + sign_in(scope: get_owner_scope) + name = get_one_gem_name + + add_owners name, options[:add] + remove_owners name, options[:remove] + show_owners name + end + + def show_owners(name) + Gem.load_yaml + + response = rubygems_api_request :get, "api/v1/gems/#{name}/owners.yaml" do |request| + request.add_field "Authorization", api_key + end + + with_response response do |resp| + owners = Gem::SafeYAML.safe_load clean_text(resp.body) + + say "Owners for gem: #{name}" + owners.each do |owner| + identifier = owner["email"] || owner["handle"] || owner["id"] + say "- #{identifier} (#{owner["role"]})" + end + end + end + + def add_owners(name, owners) + manage_owners :post, name, owners + end + + def remove_owners(name, owners) + manage_owners :delete, name, owners + end + + def manage_owners(method, name, owners) + owners.each do |owner| + response = send_owner_request(method, name, owner) + action = method == :delete ? "Removing" : "Adding" + + with_response response, "#{action} #{owner}" + rescue Gem::WebauthnVerificationError => e + raise e + rescue StandardError + # ignore early exits to allow for completing the iteration of all owners + end + end + + private + + def send_owner_request(method, name, owner) + rubygems_api_request method, "api/v1/gems/#{name}/owners", scope: get_owner_scope(method: method) do |request| + request.set_form_data "email" => owner + request.add_field "Authorization", api_key + end + end + + def get_owner_scope(method: nil) + if method == :post || options.any? && options[:add].any? + :add_owner + elsif method == :delete || options.any? && options[:remove].any? + :remove_owner + end + end +end diff --git a/lib/rubygems/commands/pristine_command.rb b/lib/rubygems/commands/pristine_command.rb new file mode 100644 index 0000000000..10978c2af7 --- /dev/null +++ b/lib/rubygems/commands/pristine_command.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../package" +require_relative "../installer" +require_relative "../version_option" + +class Gem::Commands::PristineCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "pristine", + "Restores installed gems to pristine condition from files located in the gem cache", + version: Gem::Requirement.default, + extensions: true, + extensions_set: false, + all: false + + add_option("--all", + "Restore all installed gems to pristine", + "condition") do |value, options| + options[:all] = value + end + + add_option("--skip=gem_name", + "used on --all, skip if name == gem_name") do |value, options| + options[:skip] ||= [] + options[:skip] << value + end + + add_option("--[no-]extensions", + "Restore gems with extensions", + "in addition to regular gems") do |value, options| + options[:extensions_set] = true + options[:extensions] = value + end + + add_option("--only-missing-extensions", + "Only restore gems with missing extensions") do |value, options| + options[:only_missing_extensions] = value + end + + add_option("--only-executables", + "Only restore executables") do |value, options| + options[:only_executables] = value + end + + add_option("--only-plugins", + "Only restore plugins") do |value, options| + options[:only_plugins] = value + end + + add_option("-E", "--[no-]env-shebang", + "Rewrite executables with a shebang", + "of /usr/bin/env") do |value, options| + options[:env_shebang] = value + end + + add_option("-i", "--install-dir DIR", + "Gem repository to get gems restored") do |value, options| + options[:install_dir] = File.expand_path(value) + end + + add_option("-n", "--bindir DIR", + "Directory where executables are", + "located") do |value, options| + options[:bin_dir] = File.expand_path(value) + end + + add_version_option("restore to", "pristine condition") + end + + def arguments # :nodoc: + "GEMNAME gem to restore to pristine condition (unless --all)" + end + + def defaults_str # :nodoc: + "--extensions" + end + + def description # :nodoc: + <<-EOF +The pristine command compares an installed gem with the contents of its +cached .gem file and restores any files that don't match the cached .gem's +copy. + +If you have made modifications to an installed gem, the pristine command +will revert them. All extensions are rebuilt and all bin stubs for the gem +are regenerated after checking for modifications. + +Rebuilding extensions also refreshes C-extension gems against updated system +libraries (for example after OS or package upgrades) to avoid mismatches like +outdated library version warnings. + +If the cached gem cannot be found it will be downloaded. + +If --no-extensions is provided pristine will not attempt to restore a gem +with an extension. + +If --extensions is given (but not --all or gem names) only gems with +extensions will be restored. + EOF + end + + def usage # :nodoc: + "#{program_name} [GEMNAME ...]" + end + + def execute + install_dir = options[:install_dir] + + specification_record = install_dir ? Gem::SpecificationRecord.from_path(install_dir) : Gem::Specification.specification_record + + specs = if options[:all] + specification_record.map + + # `--extensions` must be explicitly given to pristine only gems + # with extensions. + elsif options[:extensions_set] && + options[:extensions] && options[:args].empty? + specification_record.select do |spec| + spec.extensions && !spec.extensions.empty? + end + elsif options[:only_missing_extensions] + specification_record.select(&:missing_extensions?) + else + get_all_gem_names.sort.flat_map do |gem_name| + specification_record.find_all_by_name(gem_name, options[:version]).reverse + end + end + + specs = specs.select {|spec| spec.platform == RUBY_ENGINE || Gem::Platform.local === spec.platform || spec.platform == Gem::Platform::RUBY } + + if specs.to_a.empty? + if options[:only_missing_extensions] + say "No gems with missing extensions to restore" + return + end + + raise Gem::Exception, + "Failed to find gems #{options[:args]} #{options[:version]}" + end + + say "Restoring gems to pristine condition..." + + specs.group_by(&:full_name_with_location).values.each do |grouped_specs| + spec = grouped_specs.find {|s| !s.default_gem? } || grouped_specs.first + + only_executables = options[:only_executables] + only_plugins = options[:only_plugins] + + unless only_executables || only_plugins + # Default gemspecs include changes provided by ruby-core installer that + # can't currently be pristined (inclusion of compiled extension targets in + # the file list). So stick to resetting executables if it's a default gem. + only_executables = true if spec.default_gem? + end + + if options.key? :skip + if options[:skip].include? spec.name + say "Skipped #{spec.full_name}, it was given through options" + next + end + end + + unless spec.extensions.empty? || options[:extensions] || only_executables || only_plugins + say "Skipped #{spec.full_name_with_location}, it needs to compile an extension" + next + end + + gem = spec.cache_file + + unless File.exist?(gem) || only_executables || only_plugins + require_relative "../remote_fetcher" + + say "Cached gem for #{spec.full_name_with_location} not found, attempting to fetch..." + + dep = Gem::Dependency.new spec.name, spec.version + found, _ = Gem::SpecFetcher.fetcher.spec_for_dependency dep + + if found.empty? + say "Skipped #{spec.full_name}, it was not found from cache and remote sources" + next + end + + spec_candidate, source = found.first + Gem::RemoteFetcher.fetcher.download spec_candidate, source.uri.to_s, spec.base_dir + end + + env_shebang = + if options.include? :env_shebang + options[:env_shebang] + else + install_defaults = Gem::ConfigFile::PLATFORM_DEFAULTS["install"] + install_defaults.to_s["--env-shebang"] + end + + bin_dir = options[:bin_dir] if options[:bin_dir] + + installer_options = { + wrappers: true, + force: true, + install_dir: install_dir || spec.base_dir, + env_shebang: env_shebang, + build_args: spec.build_args, + bin_dir: bin_dir, + } + + if only_executables + installer = Gem::Installer.for_spec(spec, installer_options) + installer.generate_bin + elsif only_plugins + installer = Gem::Installer.for_spec(spec, installer_options) + installer.generate_plugins + else + installer = Gem::Installer.at(gem, installer_options) + installer.install + end + + say "Restored #{spec.full_name_with_location}" + end + end +end diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb new file mode 100644 index 0000000000..02931b3025 --- /dev/null +++ b/lib/rubygems/commands/push_command.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../gemcutter_utilities" +require_relative "../package" + +class Gem::Commands::PushCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::GemcutterUtilities + + def description # :nodoc: + <<-EOF +The push command uploads a gem to the push server (the default is +https://rubygems.org) and adds it to the index. + +The gem can be removed from the index and deleted from the server using the yank +command. For further discussion see the help for the yank command. + +The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. + EOF + end + + def arguments # :nodoc: + "GEM built gem to push up" + end + + def usage # :nodoc: + "#{program_name} GEM" + end + + def initialize + super "push", "Push a gem up to the gem server", host: host, attestations: [] + + @user_defined_host = false + + add_proxy_option + add_key_option + add_otp_option + + add_option("--host HOST", + "Push to another gemcutter-compatible host", + " (e.g. https://rubygems.org)") do |value, options| + options[:host] = value + @user_defined_host = true + end + + add_option("--attestation FILE", + "Push with sigstore attestations") do |value, options| + options[:attestations] << value + end + + @host = nil + end + + def execute + gem_name = get_one_gem_name + default_gem_server, push_host = get_hosts_for(gem_name) + + @host = if @user_defined_host + options[:host] + elsif default_gem_server + default_gem_server + elsif push_host + push_host + else + options[:host] + end + + sign_in @host, scope: get_push_scope + + send_gem(gem_name) + end + + def send_gem(name) + args = [:post, "api/v1/gems"] + + _, push_host = get_hosts_for(name) + + @host ||= push_host + + # Always include @host, even if it's nil + args += [@host, push_host] + + say "Pushing gem to #{@host || Gem.host}..." + + response = send_push_request(name, args) + + with_response response + end + + private + + def send_push_request(name, args) + # Always honor explicit --attestation option + # Auto-attestation is only supported on rubygems.org with GitHub Actions (not JRuby) + if options[:attestations].any? || (RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"]) + send_push_request_with_attestation(name, args) + else + send_push_request_without_attestation(name, args) + end + end + + def send_push_request_without_attestation(name, args) + scope = get_push_scope + rubygems_api_request(*args, scope: scope) do |request| + body = Gem.read_binary name + request.body = body + request.add_field "Content-Type", "application/octet-stream" + request.add_field "Content-Length", request.body.size + request.add_field "Authorization", api_key + end + end + + def send_push_request_with_attestation(name, args) + attestations = if options[:attestations].any? + options[:attestations].map do |attestation| + Gem.read_binary(attestation) + end + else + bundle_path = attest!(name) + begin + [Gem.read_binary(bundle_path)] + ensure + File.unlink(bundle_path) if bundle_path && File.exist?(bundle_path) + end + end + bundles = "[" + attestations.join(",") + "]" + + rubygems_api_request(*args, scope: get_push_scope) do |request| + request.set_form([ + ["gem", Gem.read_binary(name), { filename: name, content_type: "application/octet-stream" }], + ["attestations", bundles, { content_type: "application/json" }], + ], "multipart/form-data") + request.add_field "Authorization", api_key + end + rescue StandardError => e + message = "Failed to push with attestation, retrying without attestation.\n" + message += if Gem.configuration.really_verbose + e.full_message + else + e.message + end + alert_warning message + send_push_request_without_attestation(name, args) + end + + def attest!(name) + require "open3" + require "tempfile" + + tempfile = Tempfile.new([File.basename(name, ".*"), ".sigstore.json"]) + bundle = tempfile.path + tempfile.close(false) + + env = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h + out, st = Open3.capture2e( + env, + Gem.ruby, "-S", "gem", "exec", "--conservative", + "sigstore-cli", "sign", name, "--bundle", bundle, + unsetenv_others: true + ) + raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success? + + bundle + end + + def get_hosts_for(name) + gem_metadata = Gem::Package.new(name).spec.metadata + + [ + gem_metadata["default_gem_server"], + gem_metadata["allowed_push_host"], + ] + end + + def get_push_scope + :push_rubygem + end + + def attestation_supported_host? + host = (@host || Gem.host).to_s.chomp("/") + host == Gem::DEFAULT_HOST + end +end diff --git a/lib/rubygems/commands/rdoc_command.rb b/lib/rubygems/commands/rdoc_command.rb new file mode 100644 index 0000000000..62c4bf8ce9 --- /dev/null +++ b/lib/rubygems/commands/rdoc_command.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../version_option" +require_relative "../rdoc" +require "fileutils" + +class Gem::Commands::RdocCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "rdoc", "Generates RDoc for pre-installed gems", + version: Gem::Requirement.default, + include_rdoc: false, include_ri: true, overwrite: false + + add_option("--all", + "Generate RDoc/RI documentation for all", + "installed gems") do |value, options| + options[:all] = value + end + + add_option("--[no-]rdoc", + "Generate RDoc HTML") do |value, options| + options[:include_rdoc] = value + end + + add_option("--[no-]ri", + "Generate RI data") do |value, options| + options[:include_ri] = value + end + + add_option("--[no-]overwrite", + "Overwrite installed documents") do |value, options| + options[:overwrite] = value + end + + add_version_option + end + + def arguments # :nodoc: + "GEMNAME gem to generate documentation for (unless --all)" + end + + def defaults_str # :nodoc: + "--version '#{Gem::Requirement.default}' --ri --no-overwrite" + end + + def description # :nodoc: + <<-DESC +The rdoc command builds documentation for installed gems. By default +only documentation is built using rdoc, but additional types of +documentation may be built through rubygems plugins and the +Gem.post_installs hook. + +Use --overwrite to force rebuilding of documentation. + DESC + end + + def usage # :nodoc: + "#{program_name} [args]" + end + + def execute + specs = if options[:all] + Gem::Specification.to_a + else + get_all_gem_names.flat_map do |name| + Gem::Specification.find_by_name name, options[:version] + end.uniq + end + + if specs.empty? + alert_error "No matching gems found" + terminate_interaction 1 + end + + specs.each do |spec| + doc = Gem::RDoc.new spec, options[:include_rdoc], options[:include_ri] + + doc.force = options[:overwrite] + + if options[:overwrite] + FileUtils.rm_rf File.join(spec.doc_dir, "ri") + FileUtils.rm_rf File.join(spec.doc_dir, "rdoc") + end + + doc.generate + end + end +end diff --git a/lib/rubygems/commands/rebuild_command.rb b/lib/rubygems/commands/rebuild_command.rb new file mode 100644 index 0000000000..23b9d7b3ba --- /dev/null +++ b/lib/rubygems/commands/rebuild_command.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "tmpdir" +require_relative "../gemspec_helpers" +require_relative "../package" + +class Gem::Commands::RebuildCommand < Gem::Command + include Gem::GemspecHelpers + + def initialize + super "rebuild", "Attempt to reproduce a build of a gem." + + add_option "--diff", "If the files don't match, compare them using diffoscope." do |_value, options| + options[:diff] = true + end + + add_option "--force", "Skip validation of the spec." do |_value, options| + options[:force] = true + end + + add_option "--strict", "Consider warnings as errors when validating the spec." do |_value, options| + options[:strict] = true + end + + add_option "--source GEM_SOURCE", "Specify the source to download the gem from." do |value, options| + options[:source] = value + end + + add_option "--original GEM_FILE", "Specify a local file to compare against (instead of downloading it)." do |value, options| + options[:original_gem_file] = value + end + + add_option "--gemspec GEMSPEC_FILE", "Specify the name of the gemspec file." do |value, options| + options[:gemspec_file] = value + end + + add_option "-C PATH", "Run as if gem build was started in <PATH> instead of the current working directory." do |value, options| + options[:build_path] = value + end + end + + def arguments # :nodoc: + "GEM_NAME gem name on gem server\n" \ + "GEM_VERSION gem version you are attempting to rebuild" + end + + def description # :nodoc: + <<-EOF +The rebuild command allows you to (attempt to) reproduce a build of a gem +from a ruby gemspec. + +This command assumes the gemspec can be built with the `gem build` command. +If you use any of `gem build`, `rake build`, or`rake release` in the +build/release process for a gem, it is a potential candidate. + +You will need to match the RubyGems version used, since this is included in +the Gem metadata. + +If the gem includes lockfiles (e.g. Gemfile.lock) and similar, it will +require more effort to reproduce a build. For example, it might require +more precisely matched versions of Ruby and/or Bundler to be used. + EOF + end + + def usage # :nodoc: + "#{program_name} GEM_NAME GEM_VERSION" + end + + def execute + gem_name, gem_version = get_gem_name_and_version + + old_dir, new_dir = prep_dirs + + gem_filename = "#{gem_name}-#{gem_version}.gem" + old_file = File.join(old_dir, gem_filename) + new_file = File.join(new_dir, gem_filename) + + if options[:original_gem_file] + FileUtils.copy_file(options[:original_gem_file], old_file) + else + download_gem(gem_name, gem_version, old_file) + end + + rg_version = rubygems_version(old_file) + unless rg_version == Gem::VERSION + alert_error <<-EOF +You need to use the same RubyGems version #{gem_name} v#{gem_version} was built with. + +#{gem_name} v#{gem_version} was built using RubyGems v#{rg_version}. +Gem files include the version of RubyGems used to build them. +This means in order to reproduce #{gem_filename}, you must also use RubyGems v#{rg_version}. + +You're using RubyGems v#{Gem::VERSION}. + +Please install RubyGems v#{rg_version} and try again. + EOF + terminate_interaction 1 + end + + source_date_epoch = get_timestamp(old_file).to_s + + if build_path = options[:build_path] + Dir.chdir(build_path) { build_gem(gem_name, source_date_epoch, new_file) } + else + build_gem(gem_name, source_date_epoch, new_file) + end + + compare(source_date_epoch, old_file, new_file) + end + + private + + def sha256(file) + Digest::SHA256.hexdigest(Gem.read_binary(file)) + end + + def get_timestamp(file) + mtime = nil + File.open(file, Gem.binary_mode) do |f| + Gem::Package::TarReader.new(f) do |tar| + mtime = tar.seek("metadata.gz") {|tf| tf.header.mtime } + end + end + + mtime + end + + def compare(source_date_epoch, old_file, new_file) + date = Time.at(source_date_epoch.to_i).strftime("%F %T %Z") + + old_hash = sha256(old_file) + new_hash = sha256(new_file) + + say + say "Built at: #{date} (#{source_date_epoch})" + say "Original build saved to: #{old_file}" + say "Reproduced build saved to: #{new_file}" + say "Working directory: #{options[:build_path] || Dir.pwd}" + say + say "Hash comparison:" + say " #{old_hash}\t#{old_file}" + say " #{new_hash}\t#{new_file}" + say + + if old_hash == new_hash + say "SUCCESS - original and rebuild hashes matched" + else + say "FAILURE - original and rebuild hashes did not match" + say + + if options[:diff] + if system("diffoscope", old_file, new_file).nil? + alert_error "error: could not find `diffoscope` executable" + end + else + say "Pass --diff for more details (requires diffoscope to be installed)." + end + + terminate_interaction 1 + end + end + + def prep_dirs + rebuild_dir = Dir.mktmpdir("gem_rebuild") + old_dir = File.join(rebuild_dir, "old") + new_dir = File.join(rebuild_dir, "new") + + FileUtils.mkdir_p(old_dir) + FileUtils.mkdir_p(new_dir) + + [old_dir, new_dir] + end + + def get_gem_name_and_version + args = options[:args] || [] + if args.length == 2 + gem_name, gem_version = args + elsif args.length > 2 + raise Gem::CommandLineError, "Too many arguments" + else + raise Gem::CommandLineError, "Expected GEM_NAME and GEM_VERSION arguments (gem rebuild GEM_NAME GEM_VERSION)" + end + + [gem_name, gem_version] + end + + def build_gem(gem_name, source_date_epoch, output_file) + gemspec = options[:gemspec_file] || find_gemspec("#{gem_name}.gemspec") + + if gemspec + build_package(gemspec, source_date_epoch, output_file) + else + alert_error error_message(gem_name) + terminate_interaction(1) + end + end + + def build_package(gemspec, source_date_epoch, output_file) + with_source_date_epoch(source_date_epoch) do + spec = Gem::Specification.load(gemspec) + if spec + Gem::Package.build( + spec, + options[:force], + options[:strict], + output_file + ) + else + alert_error "Error loading gemspec. Aborting." + terminate_interaction 1 + end + end + end + + def with_source_date_epoch(source_date_epoch) + old_sde = ENV["SOURCE_DATE_EPOCH"] + ENV["SOURCE_DATE_EPOCH"] = source_date_epoch.to_s + + yield + ensure + ENV["SOURCE_DATE_EPOCH"] = old_sde + end + + def error_message(gem_name) + if gem_name + "Couldn't find a gemspec file matching '#{gem_name}' in #{Dir.pwd}" + else + "Couldn't find a gemspec file in #{Dir.pwd}" + end + end + + def download_gem(gem_name, gem_version, old_file) + # This code was based loosely off the `gem fetch` command. + version = "= #{gem_version}" + dep = Gem::Dependency.new gem_name, version + + specs_and_sources, errors = + Gem::SpecFetcher.fetcher.spec_for_dependency dep + + # There should never be more than one item in specs_and_sources, + # since we search for an exact version. + spec, source = specs_and_sources[0] + + if spec.nil? + show_lookup_failure gem_name, version, errors, options[:domain] + terminate_interaction 1 + end + + download_path = source.download spec + + FileUtils.move(download_path, old_file) + + say "Downloaded #{gem_name} version #{gem_version} as #{old_file}." + end + + def rubygems_version(gem_file) + Gem::Package.new(gem_file).spec.rubygems_version + end +end diff --git a/lib/rubygems/commands/search_command.rb b/lib/rubygems/commands/search_command.rb new file mode 100644 index 0000000000..50e161ac9b --- /dev/null +++ b/lib/rubygems/commands/search_command.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../query_utils" + +class Gem::Commands::SearchCommand < Gem::Command + include Gem::QueryUtils + + def initialize + super "search", "Display remote gems whose name matches REGEXP", + domain: :remote, details: false, versions: true, + installed: nil, version: Gem::Requirement.default + + add_query_options + end + + def arguments # :nodoc: + "REGEXP regexp to search for in gem name" + end + + def defaults_str # :nodoc: + "--remote --no-details" + end + + def description # :nodoc: + <<-EOF +The search command displays remote gems whose name matches the given +regexp. + +The --details option displays additional details from the gem but will +take a little longer to complete as it must download the information +individually from the index. + +To list local gems use the list command. + EOF + end + + def usage # :nodoc: + "#{program_name} [REGEXP]" + end +end diff --git a/lib/rubygems/commands/server_command.rb b/lib/rubygems/commands/server_command.rb new file mode 100644 index 0000000000..f1dde4aa02 --- /dev/null +++ b/lib/rubygems/commands/server_command.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../command" + +unless defined? Gem::Commands::ServerCommand + class Gem::Commands::ServerCommand < Gem::Command + def initialize + super("server", "Starts up a web server that hosts the RDoc (requires rubygems-server)") + begin + Gem::Specification.find_by_name("rubygems-server").activate + rescue Gem::LoadError + # no-op + end + end + + def description # :nodoc: + <<-EOF +The server command has been moved to the rubygems-server gem. + EOF + end + + def execute + alert_error "Install the rubygems-server gem for the server command" + end + end +end diff --git a/lib/rubygems/commands/setup_command.rb b/lib/rubygems/commands/setup_command.rb new file mode 100644 index 0000000000..175599967c --- /dev/null +++ b/lib/rubygems/commands/setup_command.rb @@ -0,0 +1,667 @@ +# frozen_string_literal: true + +require_relative "../command" + +## +# Installs RubyGems itself. This command is ordinarily only available from a +# RubyGems checkout or tarball. + +class Gem::Commands::SetupCommand < Gem::Command + HISTORY_HEADER = %r{^##\s*[\d.a-zA-Z]+\s*/\s*\d{4}-\d{2}-\d{2}\s*$} + VERSION_MATCHER = %r{^##\s*([\d.a-zA-Z]+)\s*/\s*\d{4}-\d{2}-\d{2}\s*$} + + ENV_PATHS = %w[/usr/bin/env /bin/env].freeze + + def initialize + super "setup", "Install RubyGems", + format_executable: false, document: %w[ri], + force: true, + site_or_vendor: "sitelibdir", + destdir: "", prefix: "", previous_version: "", + regenerate_binstubs: true, + regenerate_plugins: true + + add_option "--previous-version=VERSION", + "Previous version of RubyGems", + "Used for changelog processing" do |version, options| + options[:previous_version] = version + end + + add_option "--prefix=PREFIX", + "Prefix path for installing RubyGems", + "Will not affect gem repository location" do |prefix, options| + options[:prefix] = File.expand_path prefix + end + + add_option "--destdir=DESTDIR", + "Root directory to install RubyGems into", + "Mainly used for packaging RubyGems" do |destdir, options| + options[:destdir] = File.expand_path destdir + end + + add_option "--[no-]vendor", + "Install into vendorlibdir not sitelibdir" do |vendor, options| + options[:site_or_vendor] = vendor ? "vendorlibdir" : "sitelibdir" + end + + add_option "--[no-]format-executable", + "Makes `gem` match ruby", + "If Ruby is ruby18, gem will be gem18" do |value, options| + options[:format_executable] = value + end + + add_option "--[no-]document [TYPES]", Array, + "Generate documentation for RubyGems", + "List the documentation types you wish to", + "generate. For example: rdoc,ri" do |value, options| + options[:document] = case value + when nil then %w[rdoc ri] + when false then [] + else value + end + end + + add_option "--[no-]rdoc", + "Generate RDoc documentation for RubyGems" do |value, options| + if value + options[:document] << "rdoc" + else + options[:document].delete "rdoc" + end + + options[:document].uniq! + end + + add_option "--[no-]ri", + "Generate RI documentation for RubyGems" do |value, options| + if value + options[:document] << "ri" + else + options[:document].delete "ri" + end + + options[:document].uniq! + end + + add_option "--[no-]regenerate-binstubs", + "Regenerate gem binstubs" do |value, options| + options[:regenerate_binstubs] = value + end + + add_option "--[no-]regenerate-plugins", + "Regenerate gem plugins" do |value, options| + options[:regenerate_plugins] = value + end + + add_option "-f", "--[no-]force", + "Forcefully overwrite binstubs" do |value, options| + options[:force] = value + end + + add_option("-E", "--[no-]env-shebang", + "Rewrite executables with a shebang", + "of /usr/bin/env") do |value, options| + options[:env_shebang] = value + end + + @verbose = nil + end + + def defaults_str # :nodoc: + "--format-executable --document ri --regenerate-binstubs" + end + + def description # :nodoc: + <<-EOF +Installs RubyGems itself. + +RubyGems installs RDoc for itself in GEM_HOME. By default this is: + #{Gem.dir} + +If you prefer a different directory, set the GEM_HOME environment variable. + +RubyGems will install the gem command with a name matching ruby's +prefix and suffix. If ruby was installed as `ruby18`, gem will be +installed as `gem18`. + +By default, this RubyGems will install gem as: + #{Gem.default_exec_format % "gem"} + EOF + end + + module MakeDirs + def mkdir_p(path, **opts) + super + (@mkdirs ||= []) << path + end + end + + def execute + @verbose = Gem.configuration.really_verbose + + require "fileutils" + if Gem.configuration.really_verbose + extend FileUtils::Verbose + else + extend FileUtils + end + extend MakeDirs + + lib_dir, bin_dir = make_destination_dirs + man_dir = generate_default_man_dir + + install_lib lib_dir + + install_executables bin_dir + + remove_old_bin_files bin_dir + + remove_old_lib_files lib_dir + + # Can be removed one we drop support for bundler 2.2.3 (the last version installing man files to man_dir) + remove_old_man_files man_dir if man_dir && File.exist?(man_dir) + + install_default_bundler_gem bin_dir + + if mode = options[:dir_mode] + @mkdirs.uniq! + File.chmod(mode, @mkdirs) + end + + say "RubyGems #{Gem::VERSION} installed" + + regenerate_binstubs(bin_dir) if options[:regenerate_binstubs] + regenerate_plugins(bin_dir) if options[:regenerate_plugins] + + uninstall_old_gemcutter + + documentation_success = install_rdoc + + say + if @verbose + say "-" * 78 + say + end + + if options[:previous_version].empty? + options[:previous_version] = Gem::VERSION.sub(/[0-9]+$/, "0") + end + + options[:previous_version] = Gem::Version.new(options[:previous_version]) + + show_release_notes + + say + say "-" * 78 + say + + say "RubyGems installed the following executables:" + say bin_file_names.map {|name| "\t#{name}\n" } + say + + unless bin_file_names.grep(/#{File::SEPARATOR}gem$/) + say "If `gem` was installed by a previous RubyGems installation, you may need" + say "to remove it by hand." + say + end + + if documentation_success + if options[:document].include? "rdoc" + say "Rdoc documentation was installed. You may now invoke:" + say " gem server" + say "and then peruse beautifully formatted documentation for your gems" + say "with your web browser." + say "If you do not wish to install this documentation in the future, use the" + say "--no-document flag, or set it as the default in your ~/.gemrc file. See" + say "'gem help env' for details." + say + end + + if options[:document].include? "ri" + say "Ruby Interactive (ri) documentation was installed. ri is kind of like man " + say "pages for Ruby libraries. You may access it like this:" + say " ri Classname" + say " ri Classname.class_method" + say " ri Classname#instance_method" + say "If you do not wish to install this documentation in the future, use the" + say "--no-document flag, or set it as the default in your ~/.gemrc file. See" + say "'gem help env' for details." + say + end + end + end + + def install_executables(bin_dir) + prog_mode = options[:prog_mode] || 0o755 + + executables = { "gem" => "exe" } + executables.each do |tool, path| + say "Installing #{tool} executable" if @verbose + + Dir.chdir path do + bin_file = "gem" + + require "tmpdir" + + dest_file = target_bin_path(bin_dir, bin_file) + bin_tmp_file = File.join Dir.tmpdir, "#{bin_file}.#{$$}" + + begin + bin = File.readlines bin_file + bin[0] = shebang + + File.open bin_tmp_file, "w" do |fp| + fp.puts bin.join + end + + install bin_tmp_file, dest_file, mode: prog_mode + bin_file_names << dest_file + ensure + rm bin_tmp_file + end + + next unless Gem.win_platform? + + begin + bin_cmd_file = File.join Dir.tmpdir, "#{bin_file}.bat" + + File.open bin_cmd_file, "w" do |file| + file.puts <<-TEXT + @ECHO OFF + @"%~dp0#{File.basename(Gem.ruby).chomp('"')}" "%~dpn0" %* + TEXT + end + + install bin_cmd_file, "#{dest_file}.bat", mode: prog_mode + ensure + rm bin_cmd_file + end + end + end + end + + def shebang + if options[:env_shebang] + ruby_name = RbConfig::CONFIG["ruby_install_name"] + @env_path ||= ENV_PATHS.find {|env_path| File.executable? env_path } + "#!#{@env_path} #{ruby_name}\n" + else + "#!#{Gem.ruby}\n" + end + end + + def install_lib(lib_dir) + libs = { "RubyGems" => "lib" } + libs["Bundler"] = "bundler/lib" + libs.each do |tool, path| + say "Installing #{tool}" if @verbose + + lib_files = files_in path + + Dir.chdir path do + install_file_list(lib_files, lib_dir) + end + end + end + + def install_rdoc + gem_doc_dir = File.join Gem.dir, "doc" + rubygems_name = "rubygems-#{Gem::VERSION}" + rubygems_doc_dir = File.join gem_doc_dir, rubygems_name + + begin + Gem.ensure_gem_subdirectories Gem.dir + rescue SystemCallError + # ignore + end + + if File.writable?(gem_doc_dir) && + (!File.exist?(rubygems_doc_dir) || + File.writable?(rubygems_doc_dir)) + say "Removing old RubyGems RDoc and ri" if @verbose + Dir[File.join(Gem.dir, "doc", "rubygems-[0-9]*")].each do |dir| + rm_rf dir + end + + require_relative "../rdoc" + + return false unless defined?(Gem::RDoc) + + fake_spec = Gem::Specification.new "rubygems", Gem::VERSION + def fake_spec.full_gem_path + File.expand_path "../../..", __dir__ + end + + generate_ri = options[:document].include? "ri" + generate_rdoc = options[:document].include? "rdoc" + + rdoc = Gem::RDoc.new fake_spec, generate_rdoc, generate_ri + rdoc.generate + + return true + elsif @verbose + say "Skipping RDoc generation, #{gem_doc_dir} not writable" + say "Set the GEM_HOME environment variable if you want RDoc generated" + end + + false + end + + def install_default_bundler_gem(bin_dir) + current_default_spec = Gem::Specification.default_stubs.find {|s| s.name == "bundler" } + specs_dir = if current_default_spec && default_dir == Gem.default_dir + all_specs_current_version = Gem::Specification.stubs.select {|s| s.full_name == current_default_spec.full_name } + + Gem::Specification.remove_spec current_default_spec + loaded_from = current_default_spec.loaded_from + File.delete(loaded_from) + + # Remove previous default gem executables if they were not shadowed by a regular gem + FileUtils.rm_rf current_default_spec.full_gem_path if all_specs_current_version.size == 1 + + File.dirname(loaded_from) + else + target_specs_dir = File.join(default_dir, "specifications", "default") + mkdir_p target_specs_dir, mode: 0o755 + target_specs_dir + end + + new_bundler_spec = Dir.chdir("bundler") { Gem::Specification.load("bundler.gemspec") } + full_name = new_bundler_spec.full_name + gemspec_path = "#{full_name}.gemspec" + + default_spec_path = File.join(specs_dir, gemspec_path) + Gem.write_binary(default_spec_path, new_bundler_spec.to_ruby) + + bundler_spec = Gem::Specification.load(default_spec_path) + + # Remove gemspec that was same version of vendored bundler. + normal_gemspec = File.join(default_dir, "specifications", gemspec_path) + if File.file? normal_gemspec + File.delete normal_gemspec + end + + # Remove gem files that were same version of vendored bundler. + if File.directory? bundler_spec.gems_dir + Dir.entries(bundler_spec.gems_dir). + select {|default_gem| File.basename(default_gem) == full_name }. + each {|default_gem| rm_r File.join(bundler_spec.gems_dir, default_gem) } + end + + require_relative "../installer" + + Dir.chdir("bundler") do + built_gem = Gem::Package.build(new_bundler_spec) + begin + installer = Gem::Installer.at( + built_gem, + env_shebang: options[:env_shebang], + format_executable: options[:format_executable], + force: options[:force], + bin_dir: bin_dir, + install_dir: default_dir, + wrappers: true + ) + # We need to install only executable and default spec files. + # lib/bundler.rb and lib/bundler/* are available under the site_ruby directory. + installer.extract_bin + installer.generate_bin + installer.write_default_spec + ensure + FileUtils.rm_f built_gem + end + end + + new_bundler_spec.executables.each {|executable| bin_file_names << target_bin_path(bin_dir, executable) } + + say "Bundler #{new_bundler_spec.version} installed" + end + + def make_destination_dirs + lib_dir, bin_dir = Gem.default_rubygems_dirs + + unless lib_dir + lib_dir, bin_dir = generate_default_dirs + end + + mkdir_p lib_dir, mode: 0o755 + mkdir_p bin_dir, mode: 0o755 + + [lib_dir, bin_dir] + end + + def generate_default_man_dir + prefix = options[:prefix] + + if prefix.empty? + man_dir = RbConfig::CONFIG["mandir"] + return unless man_dir + else + man_dir = File.join prefix, "man" + end + + prepend_destdir_if_present(man_dir) + end + + def generate_default_dirs + prefix = options[:prefix] + site_or_vendor = options[:site_or_vendor] + + if prefix.empty? + lib_dir = RbConfig::CONFIG[site_or_vendor] + bin_dir = RbConfig::CONFIG["bindir"] + else + lib_dir = File.join prefix, "lib" + bin_dir = File.join prefix, "bin" + end + + [prepend_destdir_if_present(lib_dir), prepend_destdir_if_present(bin_dir)] + end + + def files_in(dir) + Dir.chdir dir do + Dir.glob(File.join("**", "*"), File::FNM_DOTMATCH). + select {|f| !File.directory?(f) } + end + end + + def remove_old_bin_files(bin_dir) + old_bin_files = { + "gem_mirror" => "gem mirror", + "gem_server" => "gem server", + "gemlock" => "gem lock", + "gemri" => "ri", + "gemwhich" => "gem which", + "index_gem_repository.rb" => "gem generate_index", + } + + old_bin_files.each do |old_bin_file, new_name| + old_bin_path = File.join bin_dir, old_bin_file + next unless File.exist? old_bin_path + + deprecation_message = "`#{old_bin_file}` has been deprecated. Use `#{new_name}` instead." + + File.open old_bin_path, "w" do |fp| + fp.write <<-EOF +#!#{Gem.ruby} + +abort "#{deprecation_message}" + EOF + end + + next unless Gem.win_platform? + + File.open "#{old_bin_path}.bat", "w" do |fp| + fp.puts %(@ECHO.#{deprecation_message}) + end + end + end + + def remove_old_lib_files(lib_dir) + lib_dirs = { File.join(lib_dir, "rubygems") => "lib/rubygems" } + lib_dirs[File.join(lib_dir, "bundler")] = "bundler/lib/bundler" + lib_dirs.each do |old_lib_dir, new_lib_dir| + lib_files = files_in(new_lib_dir) + + old_lib_files = files_in(old_lib_dir) + + to_remove = old_lib_files - lib_files + + gauntlet_rubygems = File.join(lib_dir, "gauntlet_rubygems.rb") + to_remove << gauntlet_rubygems if File.exist? gauntlet_rubygems + + to_remove.delete_if do |file| + file.start_with? "defaults" + end + + remove_file_list(to_remove, old_lib_dir) + end + end + + def remove_old_man_files(old_man_dir) + old_man1_dir = "#{old_man_dir}/man1" + + if File.exist?(old_man1_dir) + man1_to_remove = Dir.chdir(old_man1_dir) { Dir["bundle*.1{,.txt,.ronn}"] } + + remove_file_list(man1_to_remove, old_man1_dir) + end + + old_man5_dir = "#{old_man_dir}/man5" + + if File.exist?(old_man5_dir) + man5_to_remove = Dir.chdir(old_man5_dir) { Dir["gemfile.5{,.txt,.ronn}"] } + + remove_file_list(man5_to_remove, old_man5_dir) + end + end + + def show_release_notes + release_notes = File.join Dir.pwd, "CHANGELOG.md" + + release_notes = + if File.exist? release_notes + history = File.read release_notes + + history.force_encoding Encoding::UTF_8 + + text = history.split(HISTORY_HEADER) + text.shift # correct an off-by-one generated by split + version_lines = history.scan(HISTORY_HEADER) + versions = history.scan(VERSION_MATCHER).flatten.map do |x| + Gem::Version.new(x) + end + + history_string = "" + + until versions.length == 0 || + versions.shift <= options[:previous_version] do + history_string += version_lines.shift + text.shift + end + + history_string + else + "Oh-no! Unable to find release notes!" + end + + say release_notes + end + + def uninstall_old_gemcutter + require_relative "../uninstaller" + + ui = Gem::Uninstaller.new("gemcutter", all: true, ignore: true, + version: "< 0.4") + ui.uninstall + rescue Gem::InstallError + end + + def regenerate_binstubs(bindir) + require_relative "pristine_command" + say "Regenerating binstubs" + + args = %w[--all --only-executables --silent] + args << "--bindir=#{bindir}" + args << "--install-dir=#{default_dir}" + + if options[:env_shebang] + args << "--env-shebang" + end + + command = Gem::Commands::PristineCommand.new + command.invoke(*args) + end + + def regenerate_plugins(bindir) + require_relative "pristine_command" + say "Regenerating plugins" + + args = %w[--all --only-plugins --silent] + args << "--bindir=#{bindir}" + args << "--install-dir=#{default_dir}" + + command = Gem::Commands::PristineCommand.new + command.invoke(*args) + end + + private + + def default_dir + prefix = options[:prefix] + + if prefix.empty? + dir = Gem.default_dir + else + dir = prefix + end + + prepend_destdir_if_present(dir) + end + + def prepend_destdir_if_present(path) + destdir = options[:destdir] + return path if destdir.empty? + + File.join(options[:destdir], path.gsub(/^[a-zA-Z]:/, "")) + end + + def install_file_list(files, dest_dir) + files.each do |file| + install_file file, dest_dir + end + end + + def install_file(file, dest_dir) + dest_file = File.join dest_dir, file + dest_dir = File.dirname dest_file + unless File.directory? dest_dir + mkdir_p dest_dir, mode: 0o755 + end + + install file, dest_file, mode: options[:data_mode] || 0o644 + end + + def remove_file_list(files, dir) + Dir.chdir dir do + files.each do |file| + FileUtils.rm_f file + + warn "unable to remove old file #{file} please remove it by hand" if + File.exist? file + end + end + end + + def target_bin_path(bin_dir, bin_file) + bin_file_formatted = if options[:format_executable] + Gem.default_exec_format % bin_file + else + bin_file + end + File.join bin_dir, bin_file_formatted + end + + def bin_file_names + @bin_file_names ||= [] + end +end diff --git a/lib/rubygems/commands/signin_command.rb b/lib/rubygems/commands/signin_command.rb new file mode 100644 index 0000000000..0f77908c5b --- /dev/null +++ b/lib/rubygems/commands/signin_command.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../gemcutter_utilities" + +class Gem::Commands::SigninCommand < Gem::Command + include Gem::GemcutterUtilities + + def initialize + super "signin", "Sign in to any gemcutter-compatible host. "\ + "It defaults to https://rubygems.org" + + add_option("--host HOST", "Push to another gemcutter-compatible host") do |value, options| + options[:host] = value + end + + add_otp_option + end + + def description # :nodoc: + "The signin command executes host sign in for a push server (the default is"\ + " https://rubygems.org). The host can be provided with the host flag or can"\ + " be inferred from the provided gem. Host resolution matches the resolution"\ + " strategy for the push command." + end + + def usage # :nodoc: + program_name + end + + def execute + sign_in options[:host] + end +end diff --git a/lib/rubygems/commands/signout_command.rb b/lib/rubygems/commands/signout_command.rb new file mode 100644 index 0000000000..bdd01e4393 --- /dev/null +++ b/lib/rubygems/commands/signout_command.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../command" + +class Gem::Commands::SignoutCommand < Gem::Command + def initialize + super "signout", "Sign out from all the current sessions." + end + + def description # :nodoc: + "The `signout` command is used to sign out from all current sessions,"\ + " allowing you to sign in using a different set of credentials." + end + + def usage # :nodoc: + program_name + end + + def execute + credentials_path = Gem.configuration.credentials_path + + if !File.exist?(credentials_path) + alert_error "You are not currently signed in." + elsif !File.writable?(credentials_path) + alert_error "File '#{Gem.configuration.credentials_path}' is read-only."\ + " Please make sure it is writable." + else + Gem.configuration.unset_api_key! + say "You have successfully signed out from all sessions." + end + end +end diff --git a/lib/rubygems/commands/sources_command.rb b/lib/rubygems/commands/sources_command.rb new file mode 100644 index 0000000000..b399af2bd3 --- /dev/null +++ b/lib/rubygems/commands/sources_command.rb @@ -0,0 +1,348 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../remote_fetcher" +require_relative "../spec_fetcher" +require_relative "../local_remote_options" + +class Gem::Commands::SourcesCommand < Gem::Command + include Gem::LocalRemoteOptions + + def initialize + require "fileutils" + + super "sources", + "Manage the sources and cache file RubyGems uses to search for gems" + + add_option "-a", "--add SOURCE_URI", "Add source" do |value, options| + options[:add] = value + end + + add_option "--append SOURCE_URI", "Append source (can be used multiple times)" do |value, options| + options[:append] = value + end + + add_option "-p", "--prepend SOURCE_URI", "Prepend source (can be used multiple times)" do |value, options| + options[:prepend] = value + end + + add_option "-l", "--list", "List sources" do |value, options| + options[:list] = value + end + + add_option "-r", "--remove SOURCE_URI", "Remove source" do |value, options| + options[:remove] = value + end + + add_option "-c", "--clear-all", "Remove all sources (clear the cache)" do |value, options| + options[:clear_all] = value + end + + add_option "-u", "--update", "Update source cache" do |value, options| + options[:update] = value + end + + add_option "-f", "--[no-]force", "Do not show any confirmation prompts and behave as if 'yes' was always answered" do |value, options| + options[:force] = value + end + + add_proxy_option + end + + def add_source(source_uri) # :nodoc: + source = build_new_source(source_uri) + source_uri = source.uri.to_s + + begin + if Gem.sources.include? source + say "source #{source_uri} already present in the cache" + else + source.load_specs :released + Gem.sources << source + Gem.configuration.write + + say "#{source_uri} added to sources" + end + rescue Gem::URI::Error, ArgumentError + say "#{source_uri} is not a URI" + terminate_interaction 1 + rescue Gem::RemoteFetcher::FetchError => e + say "Error fetching #{Gem::Uri.redact(source.uri)}:\n\t#{e.message}" + terminate_interaction 1 + end + end + + def append_source(source_uri) # :nodoc: + source = build_new_source(source_uri) + source_uri = source.uri.to_s + + begin + source.load_specs :released + was_present = Gem.sources.include?(source) + Gem.sources.append source + Gem.configuration.write + + if was_present + say "#{source_uri} moved to end of sources" + else + say "#{source_uri} added to sources" + end + rescue Gem::URI::Error, ArgumentError + say "#{source_uri} is not a URI" + terminate_interaction 1 + rescue Gem::RemoteFetcher::FetchError => e + say "Error fetching #{Gem::Uri.redact(source.uri)}:\n\t#{e.message}" + terminate_interaction 1 + end + end + + def prepend_source(source_uri) # :nodoc: + source = build_new_source(source_uri) + source_uri = source.uri.to_s + + begin + source.load_specs :released + was_present = Gem.sources.include?(source) + Gem.sources.prepend source + Gem.configuration.write + + if was_present + say "#{source_uri} moved to top of sources" + else + say "#{source_uri} added to sources" + end + rescue Gem::URI::Error, ArgumentError + say "#{source_uri} is not a URI" + terminate_interaction 1 + rescue Gem::RemoteFetcher::FetchError => e + say "Error fetching #{Gem::Uri.redact(source.uri)}:\n\t#{e.message}" + terminate_interaction 1 + end + end + + def check_typo_squatting(source) + if source.typo_squatting?("rubygems.org") + question = <<-QUESTION.chomp +#{source.uri} is too similar to https://rubygems.org + +Do you want to add this source? + QUESTION + + terminate_interaction 1 unless options[:force] || ask_yes_no(question) + end + end + + def normalize_source_uri(source_uri) # :nodoc: + # Ensure the source URI has a trailing slash for proper RFC 2396 path merging + # Without a trailing slash, the last path segment is treated as a file and removed + # during relative path resolution (e.g., "/blish" + "gems/foo.gem" = "/gems/foo.gem") + # With a trailing slash, it's treated as a directory (e.g., "/blish/" + "gems/foo.gem" = "/blish/gems/foo.gem") + uri = Gem::URI.parse(source_uri) + uri.path = uri.path.gsub(%r{/+\z}, "") + "/" if uri.path && !uri.path.empty? + uri.to_s + rescue Gem::URI::Error + # If parsing fails, return the original URI and let later validation handle it + source_uri + end + + def check_rubygems_https(source_uri) # :nodoc: + uri = Gem::URI source_uri + + if uri.scheme && uri.scheme.casecmp("http").zero? && + uri.host.casecmp("rubygems.org").zero? + question = <<-QUESTION.chomp +https://rubygems.org is recommended for security over #{uri} + +Do you want to add this insecure source? + QUESTION + + terminate_interaction 1 unless options[:force] || ask_yes_no(question) + end + end + + def clear_all # :nodoc: + path = Gem.spec_cache_dir + FileUtils.rm_rf path + + if File.exist? path + if File.writable? path + say "*** Unable to remove source cache ***" + else + say "*** Unable to remove source cache (write protected) ***" + end + + terminate_interaction 1 + else + say "*** Removed specs cache ***" + end + end + + def defaults_str # :nodoc: + "--list" + end + + def description # :nodoc: + <<-EOF +RubyGems fetches gems from the sources you have configured (stored in your +~/.gemrc). + +The default source is https://rubygems.org, but you may have other sources +configured. This guide will help you update your sources or configure +yourself to use your own gem server. + +Without any arguments the sources lists your currently configured sources: + + $ gem sources + *** NO CONFIGURED SOURCES, DEFAULT SOURCES LISTED BELOW *** + + https://rubygems.org + +This may list multiple sources or non-rubygems sources. You probably +configured them before or have an old `~/.gemrc`. If you have sources you +do not recognize you should remove them. + +RubyGems has been configured to serve gems via the following URLs through +its history: + +* http://gems.rubyforge.org (RubyGems 1.3.5 and earlier) +* http://rubygems.org (RubyGems 1.3.6 through 1.8.30, and 2.0.0) +* https://rubygems.org (RubyGems 2.0.1 and newer) + +Since all of these sources point to the same set of gems you only need one +of them in your list. https://rubygems.org is recommended as it brings the +protections of an SSL connection to gem downloads. + +To add a private gem source use the --prepend argument to insert it before +the default source. This is usually the best place for private gem sources: + + $ gem sources --prepend https://my.private.source + https://my.private.source added to sources + +RubyGems will check to see if gems can be installed from the source given +before it is added. + +To add or move a source after all other sources, use --append: + + $ gem sources --append https://rubygems.org + https://rubygems.org moved to end of sources + +To remove a source use the --remove argument: + + $ gem sources --remove https://my.private.source/ + https://my.private.source/ removed from sources + + EOF + end + + def list # :nodoc: + if configured_sources + header = "*** CURRENT SOURCES ***" + list = configured_sources + else + header = "*** NO CONFIGURED SOURCES, DEFAULT SOURCES LISTED BELOW ***" + list = Gem.sources + end + + say header + say + + list.each do |src| + say src + end + end + + def list? # :nodoc: + !(options[:add] || + options[:prepend] || + options[:append] || + options[:clear_all] || + options[:remove] || + options[:update]) + end + + def execute + clear_all if options[:clear_all] + + add_source options[:add] if options[:add] + + prepend_source options[:prepend] if options[:prepend] + + append_source options[:append] if options[:append] + + remove_source options[:remove] if options[:remove] + + update if options[:update] + + list if list? + end + + def remove_source(source_uri) # :nodoc: + source = build_source(source_uri) + source_uri = source.uri.to_s + + if configured_sources&.include? source + Gem.sources.delete source + Gem.configuration.write + + if default_sources.include?(source) && configured_sources.one? + alert_warning "Removing a default source when it is the only source has no effect. Add a different source to #{config_file_name} if you want to stop using it as a source." + else + say "#{source_uri} removed from sources" + end + elsif configured_sources + say "source #{source_uri} cannot be removed because it's not present in #{config_file_name}" + else + say "source #{source_uri} cannot be removed because there are no configured sources in #{config_file_name}" + end + end + + def update # :nodoc: + Gem.sources.each_source do |src| + src.load_specs :released + src.load_specs :latest + end + + say "source cache successfully updated" + end + + def remove_cache_file(desc, path) # :nodoc: + FileUtils.rm_rf path + + if !File.exist?(path) + say "*** Removed #{desc} source cache ***" + elsif !File.writable?(path) + say "*** Unable to remove #{desc} source cache (write protected) ***" + else + say "*** Unable to remove #{desc} source cache ***" + end + end + + private + + def default_sources + Gem::SourceList.from(Gem.default_sources) + end + + def configured_sources + return @configured_sources if defined?(@configured_sources) + + configuration_sources = Gem.configuration.sources + @configured_sources = Gem::SourceList.from(configuration_sources) if configuration_sources + end + + def config_file_name + Gem.configuration.config_file_name + end + + def build_source(source_uri) + source_uri = normalize_source_uri(source_uri) + Gem::Source.new(source_uri) + end + + def build_new_source(source_uri) + source = build_source(source_uri) + check_rubygems_https(source.uri.to_s) + check_typo_squatting(source) + source + end +end diff --git a/lib/rubygems/commands/specification_command.rb b/lib/rubygems/commands/specification_command.rb new file mode 100644 index 0000000000..15e543f1a6 --- /dev/null +++ b/lib/rubygems/commands/specification_command.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../version_option" +require_relative "../package" + +class Gem::Commands::SpecificationCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::VersionOption + + def initialize + Gem.load_yaml + + super "specification", "Display gem specification (in yaml)", + domain: :local, version: Gem::Requirement.default, + format: :yaml + + add_version_option("examine") + add_platform_option + add_prerelease_option + + add_option("--all", "Output specifications for all versions of", + "the gem") do |_value, options| + options[:all] = true + end + + add_option("--ruby", "Output ruby format") do |_value, options| + options[:format] = :ruby + end + + add_option("--yaml", "Output YAML format") do |_value, options| + options[:format] = :yaml + end + + add_option("--marshal", "Output Marshal format") do |_value, options| + options[:format] = :marshal + end + + add_local_remote_options + end + + def arguments # :nodoc: + <<-ARGS +GEM_OR_FILE gem name or a .gem file to show the gemspec for +FIELD name of gemspec field to show + ARGS + end + + def defaults_str # :nodoc: + "--local --version '#{Gem::Requirement.default}' --yaml" + end + + def description # :nodoc: + <<-EOF +The specification command allows you to extract the specification from +a gem for examination. + +The specification can be output in YAML, ruby or Marshal formats. + +Specific fields in the specification can be extracted in YAML format: + + $ gem spec rake summary + --- Ruby based make-like utility. + ... + + EOF + end + + def usage # :nodoc: + "#{program_name} [GEM_OR_FILE] [FIELD]" + end + + def execute + specs = [] + gem = options[:args].shift + + unless gem + raise Gem::CommandLineError, + "Please specify a gem name or a .gem file on the command line" + end + + case v = options[:version] + when String + req = Gem::Requirement.create v + when Gem::Requirement + req = v + else + raise Gem::CommandLineError, "Unsupported version type: '#{v}'" + end + + if !req.none? && options[:all] + alert_error "Specify --all or -v, not both" + terminate_interaction 1 + end + + if options[:all] + dep = Gem::Dependency.new gem + else + dep = Gem::Dependency.new gem, req + end + + field = get_one_optional_argument + + raise Gem::CommandLineError, "--ruby and FIELD are mutually exclusive" if + field && options[:format] == :ruby + + if local? + if File.exist? gem + begin + specs << Gem::Package.new(gem).spec + rescue StandardError + nil + end + end + + if specs.empty? + specs.push(*dep.matching_specs) + end + end + + if remote? + dep.prerelease = options[:prerelease] + found, _ = Gem::SpecFetcher.fetcher.spec_for_dependency dep + + specs.push(*found.map {|spec,| spec }) + end + + if specs.empty? + alert_error "No gem matching '#{dep}' found" + terminate_interaction 1 + end + + platform = get_platform_from_requirements(options) + + if platform + specs = specs.select {|s| s.platform.to_s == platform } + end + + unless options[:all] + specs = [specs.max_by(&:version)] + end + + specs.each do |s| + s = s.send field if field + + say case options[:format] + when :ruby then s.to_ruby + when :marshal then Marshal.dump s + else Gem.use_psych? ? s.to_yaml : Gem::YAMLSerializer.dump(s) + end + + say "\n" + end + end +end diff --git a/lib/rubygems/commands/stale_command.rb b/lib/rubygems/commands/stale_command.rb new file mode 100644 index 0000000000..0be2b85159 --- /dev/null +++ b/lib/rubygems/commands/stale_command.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../command" + +class Gem::Commands::StaleCommand < Gem::Command + def initialize + super("stale", "List gems along with access times") + end + + def description # :nodoc: + <<-EOF +The stale command lists the latest access time for all the files in your +installed gems. + +You can use this command to discover gems and gem versions you are no +longer using. + EOF + end + + def usage # :nodoc: + program_name.to_s + end + + def execute + gem_to_atime = {} + Gem::Specification.each do |spec| + name = spec.full_name + Dir["#{spec.full_gem_path}/**/*.*"].each do |file| + next if File.directory?(file) + stat = File.stat(file) + gem_to_atime[name] ||= stat.atime + gem_to_atime[name] = stat.atime if gem_to_atime[name] < stat.atime + end + end + + gem_to_atime.sort_by {|_, atime| atime }.each do |name, atime| + say "#{name} at #{atime.strftime "%c"}" + end + end +end diff --git a/lib/rubygems/commands/uninstall_command.rb b/lib/rubygems/commands/uninstall_command.rb new file mode 100644 index 0000000000..3c26074f93 --- /dev/null +++ b/lib/rubygems/commands/uninstall_command.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../version_option" +require_relative "../uninstaller" +require "fileutils" + +## +# Gem uninstaller command line tool +# +# See `gem help uninstall` + +class Gem::Commands::UninstallCommand < Gem::Command + include Gem::VersionOption + + def initialize + super "uninstall", "Uninstall gems from the local repository", + version: Gem::Requirement.default, user_install: true, + check_dev: false, vendor: false + + add_option("-a", "--[no-]all", + "Uninstall all matching versions") do |value, options| + options[:all] = value + end + + add_option("-I", "--[no-]ignore-dependencies", + "Ignore dependency requirements while", + "uninstalling") do |value, options| + options[:ignore] = value + end + + add_option("-D", "--[no-]check-development", + "Check development dependencies while uninstalling", + "(default: false)") do |value, options| + options[:check_dev] = value + end + + add_option("-x", "--[no-]executables", + "Uninstall applicable executables without", + "confirmation") do |value, options| + options[:executables] = value + end + + add_option("-i", "--install-dir DIR", + "Directory to uninstall gem from") do |value, options| + options[:install_dir] = File.expand_path(value) + end + + add_option("-n", "--bindir DIR", + "Directory to remove executables from") do |value, options| + options[:bin_dir] = File.expand_path(value) + end + + add_option("--[no-]user-install", + "Uninstall from user's home directory", + "in addition to GEM_HOME.") do |value, options| + options[:user_install] = value + end + + add_option("--[no-]format-executable", + "Assume executable names match Ruby's prefix and suffix.") do |value, options| + options[:format_executable] = value + end + + add_option("--[no-]force", + "Uninstall all versions of the named gems", + "ignoring dependencies") do |value, options| + options[:force] = value + end + + add_option("--[no-]abort-on-dependent", + "Prevent uninstalling gems that are", + "depended on by other gems.") do |value, options| + options[:abort_on_dependent] = value + end + + add_version_option + add_platform_option + + add_option("--vendor", + "Uninstall gem from the vendor directory.", + "Only for use by gem repackagers.") do |_value, options| + unless Gem.vendor_dir + raise Gem::OptionParser::InvalidOption.new "your platform is not supported" + end + + alert_warning "Use your OS package manager to uninstall vendor gems" + options[:vendor] = true + options[:install_dir] = Gem.vendor_dir + end + end + + def arguments # :nodoc: + "GEMNAME name of gem to uninstall" + end + + def defaults_str # :nodoc: + "--version '#{Gem::Requirement.default}' --no-force " \ + "--user-install" + end + + def description # :nodoc: + <<-EOF +The uninstall command removes a previously installed gem. + +RubyGems will ask for confirmation if you are attempting to uninstall a gem +that is a dependency of an existing gem. You can use the +--ignore-dependencies option to skip this check. + EOF + end + + def usage # :nodoc: + "#{program_name} GEMNAME [GEMNAME ...]" + end + + def check_version # :nodoc: + if options[:version] != Gem::Requirement.default && + get_all_gem_names.size > 1 + alert_error "Can't use --version with multiple gems. You can specify multiple gems with" \ + " version requirements using `gem uninstall 'my_gem:1.0.0' 'my_other_gem:>=2'`" + terminate_interaction 1 + end + end + + def execute + check_version + + # Consider only gem specifications installed at `--install-dir` + Gem::Specification.dirs = options[:install_dir] if options[:install_dir] + + if options[:all] && !options[:args].empty? + uninstall_specific + elsif options[:all] + uninstall_all + else + uninstall_specific + end + end + + def uninstall_all + specs = Gem::Specification.reject(&:default_gem?) + + specs.each do |spec| + options[:version] = spec.version + uninstall_gem spec.name + end + + alert "Uninstalled all gems in #{options[:install_dir] || Gem.dir}" + end + + def uninstall_specific + deplist = Gem::DependencyList.new + original_gem_version = {} + + get_all_gem_names_and_versions.each do |name, version| + original_gem_version[name] = version || options[:version] + + gem_specs = Gem::Specification.find_all_by_name(name, original_gem_version[name]) + + if gem_specs.empty? + say("Gem '#{name}' is not installed") + else + gem_specs.reject!(&:default_gem?) if gem_specs.size > 1 + + gem_specs.each do |spec| + deplist.add spec + end + end + end + + deps = deplist.strongly_connected_components.flatten.reverse + + gems_to_uninstall = {} + + deps.each do |dep| + if original_gem_version[dep.name] == Gem::Requirement.default + next if gems_to_uninstall[dep.name] + gems_to_uninstall[dep.name] = true + else + options[:version] = dep.version + end + + uninstall_gem(dep.name) + end + end + + def uninstall_gem(gem_name) + uninstall(gem_name) + rescue Gem::GemNotInHomeException => e + spec = e.spec + alert("In order to remove #{spec.name}, please execute:\n" \ + "\tgem uninstall #{spec.name} --install-dir=#{spec.base_dir}") + rescue Gem::UninstallError => e + spec = e.spec + alert_error("Error: unable to successfully uninstall '#{spec.name}' which is " \ + "located at '#{spec.full_gem_path}'. This is most likely because" \ + "the current user does not have the appropriate permissions") + terminate_interaction 1 + end + + def uninstall(gem_name) + Gem::Uninstaller.new(gem_name, options).uninstall + end +end diff --git a/lib/rubygems/commands/unpack_command.rb b/lib/rubygems/commands/unpack_command.rb new file mode 100644 index 0000000000..c2fc720297 --- /dev/null +++ b/lib/rubygems/commands/unpack_command.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../version_option" +require_relative "../security_option" +require_relative "../remote_fetcher" +require_relative "../package" + +# forward-declare + +module Gem::Security # :nodoc: + class Policy # :nodoc: + end +end + +class Gem::Commands::UnpackCommand < Gem::Command + include Gem::VersionOption + include Gem::SecurityOption + + def initialize + require "fileutils" + + super "unpack", "Unpack an installed gem to the current directory", + version: Gem::Requirement.default, + target: Dir.pwd + + add_option("--target=DIR", + "target directory for unpacking") do |value, options| + options[:target] = value + end + + add_option("--spec", "unpack the gem specification") do |_value, options| + options[:spec] = true + end + + add_security_option + add_version_option + end + + def arguments # :nodoc: + "GEMNAME name of gem to unpack" + end + + def defaults_str # :nodoc: + "--version '#{Gem::Requirement.default}'" + end + + def description + <<-EOF +The unpack command allows you to examine the contents of a gem or modify +them to help diagnose a bug. + +You can add the contents of the unpacked gem to the load path using the +RUBYLIB environment variable or -I: + + $ gem unpack my_gem + Unpacked gem: '.../my_gem-1.0' + [edit my_gem-1.0/lib/my_gem.rb] + $ ruby -Imy_gem-1.0/lib -S other_program + +You can repackage an unpacked gem using the build command. See the build +command help for an example. + EOF + end + + def usage # :nodoc: + "#{program_name} GEMNAME" + end + + #-- + # TODO: allow, e.g., 'gem unpack rake-0.3.1'. Find a general solution for + # this, so that it works for uninstall as well. (And check other commands + # at the same time.) + + def execute + security_policy = options[:security_policy] + + get_all_gem_names.each do |name| + dependency = Gem::Dependency.new name, options[:version] + path = get_path dependency + + unless path + alert_error "Gem '#{name}' not installed nor fetchable." + next + end + + if @options[:spec] + spec, metadata = Gem::Package.raw_spec(path, security_policy) + + if metadata.nil? + alert_error "--spec is unsupported on '#{name}' (old format gem)" + next + end + + spec_file = File.basename spec.spec_file + + FileUtils.mkdir_p @options[:target] if @options[:target] + + destination = if @options[:target] + File.join @options[:target], spec_file + else + spec_file + end + + File.open destination, "w" do |io| + io.write metadata + end + else + basename = File.basename path, ".gem" + target_dir = File.expand_path basename, options[:target] + + package = Gem::Package.new path, security_policy + package.extract_files target_dir + + say "Unpacked gem: '#{target_dir}'" + end + end + end + + ## + # + # Find cached filename in Gem.path. Returns nil if the file cannot be found. + # + #-- + # TODO: see comments in get_path() about general service. + + def find_in_cache(filename) + Gem.path.each do |path| + this_path = File.join(path, "cache", filename) + return this_path if File.exist? this_path + end + + nil + end + + ## + # Return the full path to the cached gem file matching the given + # name and version requirement. Returns 'nil' if no match. + # + # Example: + # + # get_path 'rake', '> 0.4' # "/usr/lib/ruby/gems/1.8/cache/rake-0.4.2.gem" + # get_path 'rake', '< 0.1' # nil + # get_path 'rak' # nil (exact name required) + #-- + + def get_path(dependency) + return dependency.name if /\.gem$/i.match?(dependency.name) + + specs = dependency.matching_specs + + selected = specs.max_by(&:version) + + return Gem::RemoteFetcher.fetcher.download_to_cache(dependency) unless + selected + + return unless /^#{selected.name}$/i.match?(dependency.name) + + # We expect to find (basename).gem in the 'cache' directory. Furthermore, + # the name match must be exact (ignoring case). + + path = find_in_cache File.basename selected.cache_file + + return Gem::RemoteFetcher.fetcher.download_to_cache(dependency) unless path + + path + end +end diff --git a/lib/rubygems/commands/update_command.rb b/lib/rubygems/commands/update_command.rb new file mode 100644 index 0000000000..d9740d814a --- /dev/null +++ b/lib/rubygems/commands/update_command.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../command_manager" +require_relative "../dependency_installer" +require_relative "../install_update_options" +require_relative "../local_remote_options" +require_relative "../spec_fetcher" +require_relative "../version_option" +require_relative "../install_message" # must come before rdoc for messaging +require_relative "../rdoc" + +class Gem::Commands::UpdateCommand < Gem::Command + include Gem::InstallUpdateOptions + include Gem::LocalRemoteOptions + include Gem::VersionOption + + attr_reader :installer # :nodoc: + + attr_reader :updated # :nodoc: + + def initialize + options = { + force: false, + } + + options.merge!(install_update_options) + + super "update", "Update installed gems to the latest version", options + + add_install_update_options + + Gem::OptionParser.accept Gem::Version do |value| + Gem::Version.new value + + value + end + + add_option("--system [VERSION]", Gem::Version, + "Update the RubyGems system software") do |value, opts| + value ||= true + + opts[:system] = value + end + + add_local_remote_options + add_platform_option + add_prerelease_option "as update targets" + + @updated = [] + @installer = nil + end + + def arguments # :nodoc: + "GEMNAME name of gem to update" + end + + def defaults_str # :nodoc: + "--no-force --install-dir #{Gem.dir}\n" + + install_update_defaults_str + end + + def description # :nodoc: + <<-EOF +The update command will update your gems to the latest version. + +The update command does not remove the previous version. Use the cleanup +command to remove old versions. + EOF + end + + def usage # :nodoc: + "#{program_name} GEMNAME [GEMNAME ...]" + end + + def check_latest_rubygems(version) # :nodoc: + if Gem.rubygems_version == version + say "Latest version already installed. Done." + terminate_interaction + end + end + + def check_oldest_rubygems(version) # :nodoc: + if oldest_supported_version > version + alert_error "rubygems #{version} is not supported on #{RUBY_VERSION}. The oldest version supported by this ruby is #{oldest_supported_version}" + terminate_interaction 1 + end + end + + def check_update_arguments # :nodoc: + unless options[:args].empty? + alert_error "Gem names are not allowed with the --system option" + terminate_interaction 1 + end + end + + def execute + if options[:system] + update_rubygems + return + end + + gems_to_update = which_to_update( + highest_installed_gems, + options[:args].uniq + ) + + if options[:explain] + say "Gems to update:" + + gems_to_update.each do |name_tuple| + say " #{name_tuple.full_name}" + end + + return + end + + say "Updating installed gems" + + updated = update_gems gems_to_update + + installed_names = highest_installed_gems.keys + updated_names = updated.map(&:name) + not_updated_names = options[:args].uniq - updated_names + not_installed_names = not_updated_names - installed_names + up_to_date_names = not_updated_names - not_installed_names + + if updated.empty? + say "Nothing to update" + else + say "Gems updated: #{updated_names.join(" ")}" + end + say "Gems already up-to-date: #{up_to_date_names.join(" ")}" unless up_to_date_names.empty? + say "Gems not currently installed: #{not_installed_names.join(" ")}" unless not_installed_names.empty? + end + + def fetch_remote_gems(spec) # :nodoc: + dependency = Gem::Dependency.new spec.name, "> #{spec.version}" + dependency.prerelease = options[:prerelease] + + fetcher = Gem::SpecFetcher.fetcher + + spec_tuples, errors = fetcher.search_for_dependency dependency + + error = errors.find {|e| e.respond_to? :exception } + + raise error if error + + spec_tuples + end + + def highest_installed_gems # :nodoc: + hig = {} # highest installed gems + + # Get only gem specifications installed as --user-install + Gem::Specification.dirs = Gem.user_dir if options[:user_install] + + Gem::Specification.each do |spec| + if hig[spec.name].nil? || hig[spec.name].version < spec.version + hig[spec.name] = spec + end + end + + hig + end + + def highest_remote_name_tuple(spec) # :nodoc: + spec_tuples = fetch_remote_gems spec + + highest_remote_gem = spec_tuples.max + return unless highest_remote_gem + + highest_remote_gem.first + end + + def install_rubygems(spec) # :nodoc: + args = update_rubygems_arguments + version = spec.version + + update_dir = File.join spec.base_dir, "gems", "rubygems-update-#{version}" + + Dir.chdir update_dir do + say "Installing RubyGems #{version}" unless options[:silent] + + installed = preparing_gem_layout_for(version) do + system Gem.ruby, "--disable-gems", "setup.rb", *args + end + + unless options[:silent] + say "RubyGems system software updated" if installed + end + end + end + + def preparing_gem_layout_for(version) + if Gem::Version.new(version) >= Gem::Version.new("3.2.a") + yield + else + require "tmpdir" + Dir.mktmpdir("gem_update") do |tmpdir| + FileUtils.mv Gem.plugindir, tmpdir + + status = yield + + unless status + FileUtils.mv File.join(tmpdir, "plugins"), Gem.plugindir + end + + status + end + end + end + + def rubygems_target_version + version = options[:system] + update_latest = version == true + + unless update_latest + version = Gem::Version.new version + requirement = Gem::Requirement.new version + + return version, requirement + end + + version = Gem::Version.new Gem::VERSION + requirement = Gem::Requirement.new ">= #{Gem::VERSION}" + + rubygems_update = Gem::Specification.new + rubygems_update.name = "rubygems-update" + rubygems_update.version = version + + highest_remote_tup = highest_remote_name_tuple(rubygems_update) + target = highest_remote_tup ? highest_remote_tup.version : version + + [target, requirement] + end + + def update_gem(name, version = Gem::Requirement.default) + return if @updated.any? {|spec| spec.name == name } + + update_options = options.dup + update_options[:prerelease] = version.prerelease? + + @installer = Gem::DependencyInstaller.new update_options + + say "Updating #{name}" unless options[:system] + begin + @installer.install name, Gem::Requirement.new(version) + rescue Gem::InstallError, Gem::DependencyError => e + alert_error "Error installing #{name}:\n\t#{e.message}" + end + + @installer.installed_gems.each do |spec| + @updated << spec + end + end + + def update_gems(gems_to_update) + gems_to_update.uniq.sort.each do |name_tuple| + update_gem name_tuple.name, name_tuple.version + end + + @updated + end + + ## + # Update RubyGems software to the latest version. + + def update_rubygems + if Gem.disable_system_update_message + alert_error Gem.disable_system_update_message + terminate_interaction 1 + end + + check_update_arguments + + version, requirement = rubygems_target_version + + check_latest_rubygems version + + check_oldest_rubygems version + + installed_gems = Gem::Specification.find_all_by_name "rubygems-update", requirement + installed_gems = update_gem("rubygems-update", requirement) if installed_gems.empty? || installed_gems.first.version != version + return if installed_gems.empty? + + install_rubygems installed_gems.first + end + + def update_rubygems_arguments # :nodoc: + args = [] + args << "--silent" if options[:silent] + args << "--prefix" << Gem.prefix if Gem.prefix + args << "--no-document" unless options[:document].include?("rdoc") || options[:document].include?("ri") + args << "--no-format-executable" if options[:no_format_executable] + args << "--previous-version" << Gem::VERSION + args + end + + def which_to_update(highest_installed_gems, gem_names) + result = [] + + highest_installed_gems.each do |_l_name, l_spec| + next if !gem_names.empty? && + gem_names.none? {|name| name == l_spec.name } + + highest_remote_tup = highest_remote_name_tuple l_spec + next unless highest_remote_tup + + result << highest_remote_tup + end + + result + end + + private + + # + # Oldest version we support downgrading to. This is the version that + # originally ships with the oldest supported patch version of ruby. + # + def oldest_supported_version + @oldest_supported_version ||= + Gem::Version.new("3.3.3") + end +end diff --git a/lib/rubygems/commands/which_command.rb b/lib/rubygems/commands/which_command.rb new file mode 100644 index 0000000000..5ed4d9d142 --- /dev/null +++ b/lib/rubygems/commands/which_command.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative "../command" + +class Gem::Commands::WhichCommand < Gem::Command + def initialize + super "which", "Find the location of a library file you can require", + search_gems_first: false, show_all: false + + add_option "-a", "--[no-]all", "show all matching files" do |show_all, options| + options[:show_all] = show_all + end + + add_option "-g", "--[no-]gems-first", + "search gems before non-gems" do |gems_first, options| + options[:search_gems_first] = gems_first + end + end + + def arguments # :nodoc: + "FILE name of file to find" + end + + def defaults_str # :nodoc: + "--no-gems-first --no-all" + end + + def description # :nodoc: + <<-EOF +The which command is like the shell which command and shows you where +the file you wish to require lives. + +You can use the which command to help determine why you are requiring a +version you did not expect or to look at the content of a file you are +requiring to see why it does not behave as you expect. + EOF + end + + def execute + found = true + + options[:args].each do |arg| + arg = arg.sub(/#{Regexp.union(*Gem.suffixes)}$/, "") + dirs = $LOAD_PATH + + spec = Gem::Specification.find_by_path arg + + if spec + if options[:search_gems_first] + dirs = spec.full_require_paths + $LOAD_PATH + else + dirs = $LOAD_PATH + spec.full_require_paths + end + end + + paths = find_paths arg, dirs + + if paths.empty? + alert_error "Can't find Ruby library file or shared library #{arg}" + found = false + else + say paths + end + end + + terminate_interaction 1 unless found + end + + def find_paths(package_name, dirs) + result = [] + + dirs.each do |dir| + Gem.suffixes.each do |ext| + full_path = File.join dir, "#{package_name}#{ext}" + if File.exist?(full_path) && !File.directory?(full_path) + result << full_path + return result unless options[:show_all] + end + end + end + + result + end + + def usage # :nodoc: + "#{program_name} FILE [FILE ...]" + end +end diff --git a/lib/rubygems/commands/yank_command.rb b/lib/rubygems/commands/yank_command.rb new file mode 100644 index 0000000000..fbdc262549 --- /dev/null +++ b/lib/rubygems/commands/yank_command.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../version_option" +require_relative "../gemcutter_utilities" + +class Gem::Commands::YankCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::VersionOption + include Gem::GemcutterUtilities + + def description # :nodoc: + <<-EOF +The yank command permanently removes a gem you pushed to a server. + +Once you have pushed a gem several downloads will happen automatically +via the webhooks. If you accidentally pushed passwords or other sensitive +data you will need to change them immediately and yank your gem. + EOF + end + + def arguments # :nodoc: + "GEM name of gem" + end + + def usage # :nodoc: + "#{program_name} -v VERSION [-p PLATFORM] [--key KEY_NAME] [--host HOST] GEM" + end + + def initialize + super "yank", "Remove a pushed gem from the index" + + add_version_option("remove") + add_platform_option("remove") + add_otp_option + + add_option("--host HOST", + "Yank from another gemcutter-compatible host", + " (e.g. https://rubygems.org)") do |value, options| + options[:host] = value + end + + add_key_option + @host = nil + end + + def execute + @host = options[:host] + + sign_in @host, scope: get_yank_scope + + version = get_version_from_requirements(options[:version]) + platform = get_platform_from_requirements(options) + + if version + yank_gem(version, platform) + else + say "A version argument is required: #{usage}" + terminate_interaction + end + end + + def yank_gem(version, platform) + say "Yanking gem from #{host}..." + args = [:delete, version, platform, "api/v1/gems/yank"] + response = yank_api_request(*args) + + say response.body + end + + private + + def yank_api_request(method, version, platform, api) + name = get_one_gem_name + response = rubygems_api_request(method, api, host, scope: get_yank_scope) do |request| + request.add_field("Authorization", api_key) + + data = { + "gem_name" => name, + "version" => version, + } + data["platform"] = platform if platform + + request.set_form_data data + end + response + end + + def get_version_from_requirements(requirements) + requirements.requirements.first[1].version + rescue StandardError + nil + end + + def get_yank_scope + :yank_rubygem + end +end diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb new file mode 100644 index 0000000000..d5e9eb4e33 --- /dev/null +++ b/lib/rubygems/config_file.rb @@ -0,0 +1,652 @@ +# 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 +# +:bulk_threshold+:: See #bulk_threshold +# +:verbose+:: See #verbose +# +:update_sources+:: See #update_sources +# +:concurrent_downloads+:: See #concurrent_downloads +# +:cert_expiration_length_days+:: See #cert_expiration_length_days +# +:install_extension_in_lib+:: See #install_extension_in_lib +# +:ipv4_fallback_enabled+:: See #ipv4_fallback_enabled +# +:global_gem_cache+:: See #global_gem_cache +# +:use_psych+:: See #use_psych +# +:gemhome+:: See #home +# +:gempath+:: See #path +# +:sources+:: Sets Gem::sources +# +:disable_default_gem_server+:: See #disable_default_gem_server +# +:ssl_verify_mode+:: See #ssl_verify_mode +# +:ssl_ca_cert+:: See #ssl_ca_cert +# +:ssl_client_cert+:: See #ssl_client_cert +# +# 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 + DEFAULT_INSTALL_EXTENSION_IN_LIB = true + DEFAULT_GLOBAL_GEM_CACHE = false + DEFAULT_USE_PSYCH = false + + ## + # 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 + + ## + # Use a global cache for .gem files shared across all Ruby installations. + # When enabled, gems are cached to ~/.cache/gem/gems (or XDG_CACHE_HOME/gem/gems). + + attr_accessor :global_gem_cache + + ## + # Use Psych (C extension YAML parser) instead of the pure Ruby YAMLSerializer. + + attr_accessor :use_psych + + ## + # 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 + @global_gem_cache = ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] == "true" || DEFAULT_GLOBAL_GEM_CACHE + @use_psych = ENV["RUBYGEMS_USE_PSYCH"] == "true" || DEFAULT_USE_PSYCH + + 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 + concurrent_downloads install_extension_in_lib ipv4_fallback_enabled + global_gem_cache use_psych 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 + @concurrent_downloads = @hash[:concurrent_downloads] if @hash.key? :concurrent_downloads + @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 + @global_gem_cache = @hash[:global_gem_cache] if @hash.key? :global_gem_cache + @use_psych = @hash[:use_psych] if @hash.key? :use_psych + + @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)) + has_invalid_keys = config.keys.any? {|k| k.to_s.gsub(%r{https?:\/\/}, "").include?(": ") } + has_invalid_values = config.values.any? {|v| v.is_a?(String) && v.gsub(%r{https?:\/\/}, "").match?(/\A\S+: /) } + if has_invalid_keys || has_invalid_values + 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, permitted_classes: []) + return {} unless content.is_a?(Hash) + + 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.respond_to?(:empty?) && 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 diff --git a/lib/rubygems/core_ext/kernel_gem.rb b/lib/rubygems/core_ext/kernel_gem.rb new file mode 100644 index 0000000000..4e09b95c44 --- /dev/null +++ b/lib/rubygems/core_ext/kernel_gem.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Kernel + ## + # Use Kernel#gem to activate a specific version of +gem_name+. + # + # +requirements+ is a list of version requirements that the + # specified gem must match, most commonly "= example.version.number". See + # Gem::Requirement for how to specify a version requirement. + # + # If you will be activating the latest version of a gem, there is no need to + # call Kernel#gem, Kernel#require will do the right thing for you. + # + # Kernel#gem returns true if the gem was activated, otherwise false. If the + # gem could not be found, didn't match the version requirements, or a + # different version was already activated, an exception will be raised. + # + # Kernel#gem should be called *before* any require statements (otherwise + # RubyGems may load a conflicting library version). + # + # Kernel#gem only loads prerelease versions when prerelease +requirements+ + # are given: + # + # gem 'rake', '>= 1.1.a', '< 2' + # + # In older RubyGems versions, the environment variable GEM_SKIP could be + # used to skip activation of specified gems, for example to test out changes + # that haven't been installed yet. Now RubyGems defers to -I and the + # RUBYLIB environment variable to skip activation of a gem. + # + # Example: + # + # GEM_SKIP=libA:libB ruby -I../libA -I../libB ./mycode.rb + + def gem(gem_name, *requirements) # :doc: + skip_list = (ENV["GEM_SKIP"] || "").split(/:/) + raise Gem::LoadError, "skipping #{gem_name}" if skip_list.include? gem_name + + if gem_name.is_a? Gem::Dependency + unless Gem::Deprecate.skip + warn "#{Gem.location_of_caller.join ":"}:Warning: Kernel.gem no longer "\ + "accepts a Gem::Dependency object, please pass the name "\ + "and requirements directly" + end + + requirements = gem_name.requirement + gem_name = gem_name.name + end + + dep = Gem::Dependency.new(gem_name, *requirements) + + loaded = Gem.loaded_specs[gem_name] + + return false if loaded && dep.matches_spec?(loaded) + + spec = dep.to_spec + + if spec + if Gem::LOADED_SPECS_MUTEX.owned? + spec.activate + else + Gem::LOADED_SPECS_MUTEX.synchronize { spec.activate } + end + end + end + + private :gem +end diff --git a/lib/rubygems/core_ext/kernel_require.rb b/lib/rubygems/core_ext/kernel_require.rb new file mode 100644 index 0000000000..3a9bdbdc9d --- /dev/null +++ b/lib/rubygems/core_ext/kernel_require.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +require "monitor" + +module Kernel + RUBYGEMS_ACTIVATION_MONITOR = Monitor.new # :nodoc: + + # Make sure we have a reference to Ruby's original Kernel#require + unless defined?(gem_original_require) + # :stopdoc: + alias_method :gem_original_require, :require + private :gem_original_require + # :startdoc: + end + + ## + # When RubyGems is required, Kernel#require is replaced with our own which + # is capable of loading gems on demand. + # + # When you call <tt>require 'x'</tt>, this is what happens: + # * If the file can be loaded from the existing Ruby loadpath, it + # is. + # * Otherwise, installed gems are searched for a file that matches. + # If it's found in gem 'y', that gem is activated (added to the + # loadpath). + # + # The normal <tt>require</tt> functionality of returning false if + # that file has already been loaded is preserved. + + def require(path) # :doc: + return gem_original_require(path) unless Gem.discover_gems_on_require + + RUBYGEMS_ACTIVATION_MONITOR.synchronize do + path = File.path(path) + + # If +path+ belongs to a default gem, we activate it and then go straight + # to normal require + + if spec = Gem.find_default_spec(path) + name = spec.name + + next if Gem.loaded_specs[name] + + # Ensure -I beats a default gem + resolved_path = begin + rp = nil + load_path_check_index = Gem.load_path_insert_index - Gem.activated_gem_paths + Gem.suffixes.find do |s| + $LOAD_PATH[0...load_path_check_index].find do |lp| + if File.symlink? lp # for backward compatibility + next + end + + full_path = File.expand_path(File.join(lp, "#{path}#{s}")) + rp = full_path if File.file?(full_path) + end + end + rp + end + + next if resolved_path + + Kernel.send(:gem, name, Gem::Requirement.default_prerelease) + + Gem.load_bundler_extensions(Gem.loaded_specs[name].version) if name == "bundler" + + next + end + + # If there are no unresolved deps, then we can use just try + # normal require handle loading a gem from the rescue below. + + if Gem::Specification.unresolved_deps.empty? + next + end + + # If +path+ is for a gem that has already been loaded, don't + # bother trying to find it in an unresolved gem, just go straight + # to normal require. + #-- + # TODO request access to the C implementation of this to speed up RubyGems + + if Gem::Specification.find_active_stub_by_path(path) + next + end + + # Attempt to find +path+ in any unresolved gems... + + found_specs = Gem::Specification.find_in_unresolved path + + # If there are no directly unresolved gems, then try and find +path+ + # in any gems that are available via the currently unresolved gems. + # For example, given: + # + # a => b => c => d + # + # If a and b are currently active with c being unresolved and d.rb is + # requested, then find_in_unresolved_tree will find d.rb in d because + # it's a dependency of c. + # + if found_specs.empty? + found_specs = Gem::Specification.find_in_unresolved_tree path + + found_specs.each(&:activate) + + # We found +path+ directly in an unresolved gem. Now we figure out, of + # the possible found specs, which one we should activate. + else + + # Check that all the found specs are just different + # versions of the same gem + names = found_specs.map(&:name).uniq + + if names.size > 1 + raise Gem::LoadError, "#{path} found in multiple gems: #{names.join ", "}" + end + + # Ok, now find a gem that has no conflicts, starting + # at the highest version. + valid = found_specs.find {|s| !s.has_conflicts? } + + unless valid + le = Gem::LoadError.new "unable to find a version of '#{names.first}' to activate" + le.name = names.first + raise le + end + + valid.activate + end + end + + begin + gem_original_require(path) + rescue LoadError => load_error + if load_error.path == path && + RUBYGEMS_ACTIVATION_MONITOR.synchronize { Gem.try_activate(path) } + + return gem_original_require(path) + end + + raise load_error + end + end + + private :require +end diff --git a/lib/rubygems/core_ext/kernel_warn.rb b/lib/rubygems/core_ext/kernel_warn.rb new file mode 100644 index 0000000000..f806b77fab --- /dev/null +++ b/lib/rubygems/core_ext/kernel_warn.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Kernel + rubygems_path = "#{__dir__}/" # Frames to be skipped start with this path. + + original_warn = instance_method(:warn) + + remove_method :warn + + class << self + remove_method :warn + end + + module_function define_method(:warn) {|*messages, **kw| + unless uplevel = kw[:uplevel] + return original_warn.bind_call(self, *messages, **kw) + end + + # Ensure `uplevel` fits a `long` + uplevel, = [uplevel].pack("l!").unpack("l!") + + if uplevel >= 0 + start = 0 + while uplevel >= 0 + loc, = caller_locations(start, 1) + unless loc + # No more backtrace + start += uplevel + break + end + + start += 1 + + next unless path = loc.path + unless path.start_with?(rubygems_path, "<internal:") + # Non-rubygems frames + uplevel -= 1 + end + end + kw[:uplevel] = start + end + + original_warn.bind_call(self, *messages, **kw) + } +end diff --git a/lib/rubygems/core_ext/tcpsocket_init.rb b/lib/rubygems/core_ext/tcpsocket_init.rb new file mode 100644 index 0000000000..018c49dbeb --- /dev/null +++ b/lib/rubygems/core_ext/tcpsocket_init.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "socket" + +module CoreExtensions + module TCPSocketExt + def self.prepended(base) + base.prepend Initializer + end + + module Initializer + CONNECTION_TIMEOUT = 5 + IPV4_DELAY_SECONDS = 0.1 + + def initialize(host, serv, *rest) + mutex = Thread::Mutex.new + addrs = [] + threads = [] + cond_var = Thread::ConditionVariable.new + + Addrinfo.foreach(host, serv, nil, :STREAM) do |addr| + Thread.report_on_exception = false + + threads << Thread.new(addr) do + # give head start to ipv6 addresses + sleep IPV4_DELAY_SECONDS if addr.ipv4? + + # raises Errno::ECONNREFUSED when ip:port is unreachable + Socket.tcp(addr.ip_address, serv, connect_timeout: CONNECTION_TIMEOUT).close + mutex.synchronize do + addrs << addr.ip_address + cond_var.signal + end + end + end + + mutex.synchronize do + timeout_time = CONNECTION_TIMEOUT + Time.now.to_f + while addrs.empty? && (remaining_time = timeout_time - Time.now.to_f) > 0 + cond_var.wait(mutex, remaining_time) + end + + host = addrs.shift unless addrs.empty? + end + + threads.each {|t| t.kill.join if t.alive? } + + super(host, serv, *rest) + end + end + end +end + +TCPSocket.prepend CoreExtensions::TCPSocketExt diff --git a/lib/rubygems/defaults.rb b/lib/rubygems/defaults.rb new file mode 100644 index 0000000000..2247c49c81 --- /dev/null +++ b/lib/rubygems/defaults.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +module Gem + DEFAULT_HOST = "https://rubygems.org" + + @post_install_hooks ||= [] + @done_installing_hooks ||= [] + @post_uninstall_hooks ||= [] + @pre_uninstall_hooks ||= [] + @pre_install_hooks ||= [] + + ## + # An Array of the default sources that come with RubyGems + + def self.default_sources + @default_sources ||= %w[https://rubygems.org/] + end + + ## + # Default spec directory path to be used if an alternate value is not + # specified in the environment + + def self.default_spec_cache_dir + default_spec_cache_dir = File.join Gem.user_home, ".gem", "specs" + + unless File.exist?(default_spec_cache_dir) + default_spec_cache_dir = File.join Gem.cache_home, "gem", "specs" + end + + default_spec_cache_dir + end + + ## + # Default home directory path to be used if an alternate value is not + # specified in the environment + + def self.default_dir + @default_dir ||= File.join(RbConfig::CONFIG["rubylibprefix"], "gems", RbConfig::CONFIG["ruby_version"]) + end + + ## + # Returns binary extensions dir for specified RubyGems base dir or nil + # if such directory cannot be determined. + # + # By default, the binary extensions are located side by side with their + # Ruby counterparts, therefore nil is returned + + def self.default_ext_dir_for(base_dir) + nil + end + + ## + # Paths where RubyGems' .rb files and bin files are installed + + def self.default_rubygems_dirs + nil # default to standard layout + end + + ## + # Path to specification files of default gems. + + def self.default_specifications_dir + @default_specifications_dir ||= File.join(Gem.default_dir, "specifications", "default") + end + + ## + # Finds the user's home directory. + #-- + # Some comments from the ruby-talk list regarding finding the home + # directory: + # + # I have HOME, USERPROFILE and HOMEDRIVE + HOMEPATH. Ruby seems + # to be depending on HOME in those code samples. I propose that + # it should fallback to USERPROFILE and HOMEDRIVE + HOMEPATH (at + # least on Win32). + #++ + #-- + # + #++ + + def self.find_home + Dir.home.dup + rescue StandardError + if Gem.win_platform? + File.expand_path File.join(ENV["HOMEDRIVE"] || ENV["SystemDrive"], "/") + else + File.expand_path "/" + end + end + + private_class_method :find_home + + ## + # The home directory for the user. + + def self.user_home + @user_home ||= find_home + end + + ## + # Path for gems in the user's home directory + + def self.user_dir + gem_dir = File.join(Gem.user_home, ".gem") + gem_dir = File.join(Gem.data_home, "gem") unless File.exist?(gem_dir) + parts = [gem_dir, ruby_engine] + parts << RbConfig::CONFIG["ruby_version"] unless RbConfig::CONFIG["ruby_version"].empty? + File.join parts + end + + ## + # The path to standard location of the user's configuration directory. + + def self.config_home + @config_home ||= ENV["XDG_CONFIG_HOME"] || File.join(Gem.user_home, ".config") + end + + ## + # Finds the user's config file + + def self.find_config_file + gemrc = File.join Gem.user_home, ".gemrc" + if File.exist? gemrc + gemrc + else + File.join Gem.config_home, "gem", "gemrc" + end + end + + ## + # The path to standard location of the user's .gemrc file. + + def self.config_file + @config_file ||= find_config_file + end + + ## + # The path to standard location of the user's state file. + + def self.state_file + @state_file ||= File.join(Gem.state_home, "gem", "last_update_check") + end + + ## + # The path to standard location of the user's cache directory. + + def self.cache_home + @cache_home ||= ENV["XDG_CACHE_HOME"] || File.join(Gem.user_home, ".cache") + end + + ## + # The path to the global gem cache directory. + # This is used when global_gem_cache is enabled to share .gem files + # across all Ruby installations. + + def self.global_gem_cache_path + File.join(cache_home, "gem", "gems") + end + + ## + # The path to standard location of the user's data directory. + + def self.data_home + @data_home ||= ENV["XDG_DATA_HOME"] || File.join(Gem.user_home, ".local", "share") + end + + ## + # The path to standard location of the user's state directory. + + def self.state_home + @state_home ||= ENV["XDG_STATE_HOME"] || File.join(Gem.user_home, ".local", "state") + end + + ## + # How String Gem paths should be split. Overridable for esoteric platforms. + + def self.path_separator + File::PATH_SEPARATOR + end + + ## + # Default gem load path + + def self.default_path + path = [] + path << user_dir if user_home && File.exist?(user_home) + path << default_dir + path << vendor_dir if vendor_dir && File.directory?(vendor_dir) + path + end + + ## + # Deduce Ruby's --program-prefix and --program-suffix from its install name + + def self.default_exec_format + exec_format = begin + RbConfig::CONFIG["ruby_install_name"].sub("ruby", "%s") + rescue StandardError + "%s" + end + + unless exec_format.include?("%s") + raise Gem::Exception, + "[BUG] invalid exec_format #{exec_format.inspect}, no %s" + end + + exec_format + end + + ## + # The default directory for binaries + + def self.default_bindir + RbConfig::CONFIG["bindir"] + end + + def self.ruby_engine + RUBY_ENGINE + end + + ## + # The default signing key path + + def self.default_key_path + default_key_path = File.join Gem.user_home, ".gem", "gem-private_key.pem" + + unless File.exist?(default_key_path) + default_key_path = File.join Gem.data_home, "gem", "gem-private_key.pem" + end + + default_key_path + end + + ## + # The default signing certificate chain path + + def self.default_cert_path + default_cert_path = File.join Gem.user_home, ".gem", "gem-public_cert.pem" + + unless File.exist?(default_cert_path) + default_cert_path = File.join Gem.data_home, "gem", "gem-public_cert.pem" + end + + default_cert_path + end + + ## + # Enables automatic installation into user directory + + def self.default_user_install # :nodoc: + if !ENV.key?("GEM_HOME") && File.exist?(Gem.dir) && !File.writable?(Gem.dir) + Gem.ui.say "Defaulting to user installation because default installation directory (#{Gem.dir}) is not writable." + return true + end + + false + end + + ## + # Install extensions into lib as well as into the extension directory. + + def self.install_extension_in_lib # :nodoc: + Gem.configuration.install_extension_in_lib + end + + ## + # Directory where vendor gems are installed. + + def self.vendor_dir # :nodoc: + if vendor_dir = ENV["GEM_VENDOR"] + return vendor_dir.dup + end + + return nil unless RbConfig::CONFIG.key? "vendordir" + + File.join RbConfig::CONFIG["vendordir"], "gems", + RbConfig::CONFIG["ruby_version"] + end + + ## + # Default options for gem commands for Ruby packagers. + # + # The options here should be structured as an array of string "gem" + # command names as keys and a string of the default options as values. + # + # Example: + # + # def self.operating_system_defaults + # { + # 'install' => '--no-rdoc --no-ri --env-shebang', + # 'update' => '--no-rdoc --no-ri --env-shebang' + # } + # end + + def self.operating_system_defaults + {} + end + + ## + # Default options for gem commands for Ruby implementers. + # + # The options here should be structured as an array of string "gem" + # command names as keys and a string of the default options as values. + # + # Example: + # + # def self.platform_defaults + # { + # 'install' => '--no-rdoc --no-ri --env-shebang', + # 'update' => '--no-rdoc --no-ri --env-shebang' + # } + # end + + def self.platform_defaults + {} + end +end diff --git a/lib/rubygems/dependency.rb b/lib/rubygems/dependency.rb new file mode 100644 index 0000000000..1e91f493a6 --- /dev/null +++ b/lib/rubygems/dependency.rb @@ -0,0 +1,348 @@ +# frozen_string_literal: true + +## +# The Dependency class holds a Gem name and a Gem::Requirement. + +class Gem::Dependency + ## + # Valid dependency types. + #-- + # When this list is updated, be sure to change + # Gem::Specification::CURRENT_SPECIFICATION_VERSION as well. + # + # REFACTOR: This type of constant, TYPES, indicates we might want + # two classes, used via inheritance or duck typing. + + TYPES = [ + :development, + :runtime, + ].freeze + + ## + # Dependency name or regular expression. + + attr_accessor :name + + ## + # Allows you to force this dependency to be a prerelease. + + attr_writer :prerelease + + ## + # Constructs a dependency with +name+ and +requirements+. The last + # argument can optionally be the dependency type, which defaults to + # <tt>:runtime</tt>. + + def initialize(name, *requirements) + case name + when String then # ok + when Regexp then + msg = ["NOTE: Dependency.new w/ a regexp is deprecated.", + "Dependency.new called from #{Gem.location_of_caller.join(":")}"] + warn msg.join("\n") unless Gem::Deprecate.skip + else + raise ArgumentError, + "dependency name must be a String, was #{name.inspect}" + end + + type = Symbol === requirements.last ? requirements.pop : :runtime + requirements = requirements.first if requirements.length == 1 # unpack + + unless TYPES.include? type + raise ArgumentError, "Valid types are #{TYPES.inspect}, " \ + "not #{type.inspect}" + end + + @name = name + @requirement = Gem::Requirement.create requirements + @type = type + @prerelease = false + + # This is for Marshal backwards compatibility. See the comments in + # +requirement+ for the dirty details. + + @version_requirements = @requirement + end + + ## + # A dependency's hash is the XOR of the hashes of +name+, +type+, + # and +requirement+. + + def hash # :nodoc: + name.hash ^ type.hash ^ requirement.hash + end + + def inspect # :nodoc: + if prerelease? + format("<%s type=%p name=%p requirements=%p prerelease=ok>", self.class, type, name, requirement.to_s) + else + format("<%s type=%p name=%p requirements=%p>", self.class, type, name, requirement.to_s) + end + end + + ## + # Does this dependency require a prerelease? + + def prerelease? + @prerelease || requirement.prerelease? + end + + ## + # Is this dependency simply asking for the latest version + # of a gem? + + def latest_version? + @requirement.none? + end + + def pretty_print(q) # :nodoc: + q.group 1, "Gem::Dependency.new(", ")" do + q.pp name + q.text "," + q.breakable + + q.pp requirement + + q.text "," + q.breakable + + q.pp type + end + end + + ## + # What does this dependency require? + + def requirement + return @requirement if defined?(@requirement) && @requirement + + # @version_requirements and @version_requirement are legacy ivar + # names, and supported here because older gems need to keep + # working and Dependency doesn't implement marshal_dump and + # marshal_load. In a happier world, this would be an + # attr_accessor. The horrifying instance_variable_get you see + # below is also the legacy of some old restructurings. + # + # Note also that because of backwards compatibility (loading new + # gems in an old RubyGems installation), we can't add explicit + # marshaling to this class until we want to make a big + # break. Maybe 2.0. + # + # Children, define explicit marshal and unmarshal behavior for + # public classes. Marshal formats are part of your public API. + + # REFACTOR: See above + + if defined?(@version_requirement) && @version_requirement + version = @version_requirement.instance_variable_get :@version + @version_requirement = nil + @version_requirements = Gem::Requirement.new version + end + + @requirement = @version_requirements if defined?(@version_requirements) + end + + def requirements_list + requirement.as_list + end + + def to_s # :nodoc: + if type != :runtime + "#{name} (#{requirement}, #{type})" + else + "#{name} (#{requirement})" + end + end + + ## + # Dependency type. + + def type + @type ||= :runtime + end + + def runtime? + @type == :runtime || !@type + end + + def ==(other) # :nodoc: + Gem::Dependency === other && + name == other.name && + type == other.type && + requirement == other.requirement + end + + ## + # Dependencies are ordered by name. + + def <=>(other) + name <=> other.name + end + + ## + # Uses this dependency as a pattern to compare to +other+. This + # dependency will match if the name matches the other's name, and + # other has only an equal version requirement that satisfies this + # dependency. + + def =~(other) + unless Gem::Dependency === other + return unless other.respond_to?(:name) && other.respond_to?(:version) + other = Gem::Dependency.new other.name, other.version + end + + return false unless name === other.name + + reqs = other.requirement.requirements + + return false unless reqs.length == 1 + return false unless reqs.first.first == "=" + + version = reqs.first.last + + requirement.satisfied_by? version + end + + alias_method :===, :=~ + + ## + # :call-seq: + # dep.match? name => true or false + # dep.match? name, version => true or false + # dep.match? spec => true or false + # + # Does this dependency match the specification described by +name+ and + # +version+ or match +spec+? + # + # NOTE: Unlike #matches_spec? this method does not return true when the + # version is a prerelease version unless this is a prerelease dependency. + + def match?(obj, version = nil, allow_prerelease = false) + if !version + name = obj.name + version = obj.version + else + name = obj + end + + return false unless self.name === name + + version = Gem::Version.new version + + return true if requirement.none? && !version.prerelease? + return false if version.prerelease? && + !allow_prerelease && + !prerelease? + + requirement.satisfied_by? version + end + + ## + # Does this dependency match +spec+? + # + # NOTE: This is not a convenience method. Unlike #match? this method + # returns true when +spec+ is a prerelease version even if this dependency + # is not a prerelease dependency. + + def matches_spec?(spec) + return false unless name === spec.name + return true if requirement.none? + + requirement.satisfied_by?(spec.version) + end + + ## + # Merges the requirements of +other+ into this dependency + + def merge(other) + unless name == other.name + raise ArgumentError, + "#{self} and #{other} have different names" + end + + default = Gem::Requirement.default + self_req = requirement + other_req = other.requirement + + return self.class.new name, self_req if other_req == default + return self.class.new name, other_req if self_req == default + + self.class.new name, self_req.as_list.concat(other_req.as_list) + end + + def matching_specs(platform_only = false) + matches = Gem::Specification.find_all_by_name(name, requirement) + + if platform_only + matches.reject! do |spec| + spec.nil? || !Gem::Platform.match_spec?(spec) + end + end + + matches.reject(&:ignored?) + end + + ## + # True if the dependency will not always match the latest version. + + def specific? + @requirement.specific? + end + + def to_specs + matches = matching_specs true + + # TODO: check Gem.activated_spec[self.name] in case matches falls outside + + if matches.empty? + specs = Gem::Specification.stubs_for name + + if specs.empty? + raise Gem::MissingSpecError.new name, requirement + else + raise Gem::MissingSpecVersionError.new name, requirement, specs + end + end + + # TODO: any other resolver validations should go here + + matches + end + + def to_spec + matches = to_specs.compact + + active = matches.find(&:activated?) + return active if active + + unless prerelease? + # Consider prereleases only as a fallback + pre, matches = matches.partition {|spec| spec.version.prerelease? } + matches = pre if matches.empty? + end + + matches.first + end + + def identity + if prerelease? + if specific? + :complete + else + :abs_latest + end + elsif latest_version? + :latest + else + :released + end + end + + def encode_with(coder) # :nodoc: + coder.add "name", @name + coder.add "requirement", @requirement + coder.add "type", @type + coder.add "prerelease", @prerelease + coder.add "version_requirements", @version_requirements + end +end diff --git a/lib/rubygems/dependency_installer.rb b/lib/rubygems/dependency_installer.rb new file mode 100644 index 0000000000..c842714d95 --- /dev/null +++ b/lib/rubygems/dependency_installer.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require_relative "../rubygems" +require_relative "dependency_list" +require_relative "package" +require_relative "installer" +require_relative "spec_fetcher" +require_relative "user_interaction" +require_relative "available_set" + +## +# Installs a gem along with all its dependencies from local and remote gems. + +class Gem::DependencyInstaller + include Gem::UserInteraction + + DEFAULT_OPTIONS = { # :nodoc: + env_shebang: false, + document: %w[ri], + domain: :both, # HACK: dup + force: false, + format_executable: false, # HACK: dup + ignore_dependencies: false, + prerelease: false, + security_policy: nil, # HACK: NoSecurity requires OpenSSL. AlmostNo? Low? + wrappers: true, + build_args: nil, + build_docs_in_background: false, + }.freeze + + ## + # Documentation types. For use by the Gem.done_installing hook + + attr_reader :document + + ## + # Errors from SpecFetcher while searching for remote specifications + + attr_reader :errors + + ## + # List of gems installed by #install in alphabetic order + + attr_reader :installed_gems + + ## + # Creates a new installer instance. + # + # Options are: + # :cache_dir:: Alternate repository path to store .gem files in. + # :domain:: :local, :remote, or :both. :local only searches gems in the + # current directory. :remote searches only gems in Gem::sources. + # :both searches both. + # :env_shebang:: See Gem::Installer::new. + # :force:: See Gem::Installer#install. + # :format_executable:: See Gem::Installer#initialize. + # :ignore_dependencies:: Don't install any dependencies. + # :install_dir:: See Gem::Installer#install. + # :prerelease:: Allow prerelease versions. See #install. + # :security_policy:: See Gem::Installer::new and Gem::Security. + # :user_install:: See Gem::Installer.new + # :wrappers:: See Gem::Installer::new + # :build_args:: See Gem::Installer::new + + def initialize(options = {}) + @only_install_dir = !options[:install_dir].nil? + @install_dir = options[:install_dir] || Gem.dir + @build_root = options[:build_root] + + options = DEFAULT_OPTIONS.merge options + + @bin_dir = options[:bin_dir] + @dev_shallow = options[:dev_shallow] + @development = options[:development] + @document = options[:document] + @domain = options[:domain] + @env_shebang = options[:env_shebang] + @force = options[:force] + @format_executable = options[:format_executable] + @ignore_dependencies = options[:ignore_dependencies] + @prerelease = options[:prerelease] + @security_policy = options[:security_policy] + @user_install = options[:user_install] + @wrappers = options[:wrappers] + @build_args = options[:build_args] + @build_jobs = options[:build_jobs] + @build_docs_in_background = options[:build_docs_in_background] + @dir_mode = options[:dir_mode] + @data_mode = options[:data_mode] + @prog_mode = options[:prog_mode] + @build_extension = options[:build_extension] + @install_plugin = options[:install_plugin] + + # Indicates that we should not try to update any deps unless + # we absolutely must. + @minimal_deps = options[:minimal_deps] + + @available = nil + @installed_gems = [] + @toplevel_specs = nil + + @cache_dir = options[:cache_dir] || @install_dir + + @errors = [] + end + + ## + # Indicated, based on the requested domain, if local + # gems should be considered. + + def consider_local? + @domain == :both || @domain == :local + end + + ## + # Indicated, based on the requested domain, if remote + # gems should be considered. + + def consider_remote? + @domain == :both || @domain == :remote + end + + def in_background(what) # :nodoc: + fork_happened = false + if @build_docs_in_background && Process.respond_to?(:fork) + begin + Process.fork do + yield + end + fork_happened = true + say "#{what} in a background process." + rescue NotImplementedError + end + end + yield unless fork_happened + end + + ## + # Installs the gem +dep_or_name+ and all its dependencies. Returns an Array + # of installed gem specifications. + # + # If the +:prerelease+ option is set and there is a prerelease for + # +dep_or_name+ the prerelease version will be installed. + # + # Unless explicitly specified as a prerelease dependency, prerelease gems + # that +dep_or_name+ depend on will not be installed. + # + # If c-1.a depends on b-1 and a-1.a and there is a gem b-1.a available then + # c-1.a, b-1 and a-1.a will be installed. b-1.a will need to be installed + # separately. + + def install(dep_or_name, version = Gem::Requirement.default) + request_set = resolve_dependencies dep_or_name, version + + @installed_gems = [] + + options = { + bin_dir: @bin_dir, + build_args: @build_args, + build_jobs: @build_jobs, + document: @document, + env_shebang: @env_shebang, + force: @force, + format_executable: @format_executable, + ignore_dependencies: @ignore_dependencies, + prerelease: @prerelease, + security_policy: @security_policy, + user_install: @user_install, + wrappers: @wrappers, + build_root: @build_root, + dir_mode: @dir_mode, + data_mode: @data_mode, + prog_mode: @prog_mode, + build_extension: @build_extension, + install_plugin: @install_plugin, + } + options[:install_dir] = @install_dir if @only_install_dir + + request_set.install options do |_, installer| + @installed_gems << installer.spec if installer + end + + @installed_gems.sort! + + # Since this is currently only called for docs, we can be lazy and just say + # it's documentation. Ideally the hook adder could decide whether to be in + # the background or not, and what to call it. + in_background "Installing documentation" do + Gem.done_installing_hooks.each do |hook| + hook.call self, @installed_gems + end + end unless Gem.done_installing_hooks.empty? + + @installed_gems + end + + def install_development_deps # :nodoc: + if @development && @dev_shallow + :shallow + elsif @development + :all + else + :none + end + end + + def resolve_dependencies(dep_or_name, version) # :nodoc: + request_set = Gem::RequestSet.new + request_set.development = @development + request_set.development_shallow = @dev_shallow + request_set.soft_missing = @force + request_set.prerelease = @prerelease + + installer_set = Gem::Resolver::InstallerSet.new @domain + installer_set.ignore_installed = (@minimal_deps == false) || @only_install_dir + installer_set.force = @force + + if consider_local? + if dep_or_name =~ /\.gem$/ && File.file?(dep_or_name) + src = Gem::Source::SpecificFile.new dep_or_name + installer_set.add_local dep_or_name, src.spec, src + version = src.spec.version if version == Gem::Requirement.default + elsif dep_or_name =~ /\.gem$/ # rubocop:disable Performance/RegexpMatch + Dir[dep_or_name].each do |name| + src = Gem::Source::SpecificFile.new name + installer_set.add_local dep_or_name, src.spec, src + rescue Gem::Package::FormatError + end + # else This is a dependency. InstallerSet handles this case + end + end + + dependency = + if spec = installer_set.local?(dep_or_name) + installer_set.remote = nil if spec.dependencies.none? + Gem::Dependency.new spec.name, version + elsif String === dep_or_name + Gem::Dependency.new dep_or_name, version + else + dep_or_name + end + + dependency.prerelease = @prerelease + + request_set.import [dependency] + + installer_set.add_always_install dependency + + request_set.always_install = installer_set.always_install + request_set.remote = installer_set.consider_remote? + + if @ignore_dependencies + installer_set.ignore_dependencies = true + request_set.ignore_dependencies = true + request_set.soft_missing = true + end + + request_set.resolve installer_set + + @errors.concat request_set.errors + + request_set + end +end diff --git a/lib/rubygems/dependency_list.rb b/lib/rubygems/dependency_list.rb new file mode 100644 index 0000000000..d50cfe2d54 --- /dev/null +++ b/lib/rubygems/dependency_list.rb @@ -0,0 +1,242 @@ +# 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 "vendored_tsort" + +## +# Gem::DependencyList is used for installing and uninstalling gems in the +# correct order to avoid conflicts. +#-- +# TODO: It appears that all but topo-sort functionality is being duplicated +# (or is planned to be duplicated) elsewhere in rubygems. Is the majority of +# this class necessary anymore? Especially #ok?, #why_not_ok? + +class Gem::DependencyList + attr_reader :specs + + include Enumerable + include Gem::TSort + + ## + # Allows enabling/disabling use of development dependencies + + attr_accessor :development + + ## + # Creates a DependencyList from the current specs. + + def self.from_specs + list = new + list.add(*Gem::Specification.to_a) + list + end + + ## + # Creates a new DependencyList. If +development+ is true, development + # dependencies will be included. + + def initialize(development = false) + @specs = [] + + @development = development + end + + ## + # Adds +gemspecs+ to the dependency list. + + def add(*gemspecs) + @specs.concat gemspecs + end + + def clear + @specs.clear + end + + ## + # Return a list of the gem specifications in the dependency list, sorted in + # order so that no gemspec in the list depends on a gemspec earlier in the + # list. + # + # This is useful when removing gems from a set of installed gems. By + # removing them in the returned order, you don't get into as many dependency + # issues. + # + # If there are circular dependencies (yuck!), then gems will be returned in + # order until only the circular dependents and anything they reference are + # left. Then arbitrary gemspecs will be returned until the circular + # dependency is broken, after which gems will be returned in dependency + # order again. + + def dependency_order + sorted = strongly_connected_components.flatten + + result = [] + seen = {} + + sorted.each do |spec| + if index = seen[spec.name] + if result[index].version < spec.version + result[index] = spec + end + else + seen[spec.name] = result.length + result << spec + end + end + + result.reverse + end + + ## + # Iterator over dependency_order + + def each(&block) + dependency_order.each(&block) + end + + def find_name(full_name) + @specs.find {|spec| spec.full_name == full_name } + end + + def inspect # :nodoc: + format("%s %p>", super[0..-2], map(&:full_name)) + end + + ## + # Are all the dependencies in the list satisfied? + + def ok? + why_not_ok?(:quick).empty? + end + + def why_not_ok?(quick = false) + unsatisfied = Hash.new {|h,k| h[k] = [] } + each do |spec| + spec.runtime_dependencies.each do |dep| + inst = Gem::Specification.any? do |installed_spec| + dep.name == installed_spec.name && + dep.requirement.satisfied_by?(installed_spec.version) + end + + unless inst || @specs.find {|s| s.satisfies_requirement? dep } + unsatisfied[spec.name] << dep + return unsatisfied if quick + end + end + end + + unsatisfied + end + + ## + # It is ok to remove a gemspec from the dependency list? + # + # If removing the gemspec creates breaks a currently ok dependency, then it + # is NOT ok to remove the gemspec. + + def ok_to_remove?(full_name, check_dev = true) + gem_to_remove = find_name full_name + + # If the state is inconsistent, at least don't crash + return true unless gem_to_remove + + siblings = @specs.find_all do |s| + s.name == gem_to_remove.name && + s.full_name != gem_to_remove.full_name + end + + deps = [] + + @specs.each do |spec| + check = check_dev ? spec.dependencies : spec.runtime_dependencies + + check.each do |dep| + deps << dep if gem_to_remove.satisfies_requirement?(dep) + end + end + + deps.all? do |dep| + siblings.any? do |s| + s.satisfies_requirement? dep + end + end + end + + ## + # Remove everything in the DependencyList that matches but doesn't + # satisfy items in +dependencies+ (a hash of gem names to arrays of + # dependencies). + + def remove_specs_unsatisfied_by(dependencies) + specs.reject! do |spec| + dep = dependencies[spec.name] + dep && !dep.requirement.satisfied_by?(spec.version) + end + end + + ## + # Removes the gemspec matching +full_name+ from the dependency list + + def remove_by_name(full_name) + @specs.delete_if {|spec| spec.full_name == full_name } + end + + ## + # Return a hash of predecessors. <tt>result[spec]</tt> is an Array of + # gemspecs that have a dependency satisfied by the named gemspec. + + def spec_predecessors + result = Hash.new {|h,k| h[k] = [] } + + specs = @specs.sort.reverse + + specs.each do |spec| + specs.each do |other| + next if spec == other + + other.dependencies.each do |dep| + if spec.satisfies_requirement? dep + result[spec] << other + end + end + end + end + + result + end + + def tsort_each_node(&block) + @specs.each(&block) + end + + def tsort_each_child(node) + specs = @specs.sort.reverse + + dependencies = node.runtime_dependencies + dependencies.push(*node.development_dependencies) if @development + + dependencies.each do |dep| + specs.each do |spec| + if spec.satisfies_requirement? dep + yield spec + break + end + end + end + end + + private + + ## + # Count the number of gemspecs in the list +specs+ that are not in + # +ignored+. + + def active_count(specs, ignored) + specs.count {|spec| ignored[spec.full_name].nil? } + end +end diff --git a/lib/rubygems/deprecate.rb b/lib/rubygems/deprecate.rb new file mode 100644 index 0000000000..eb503bb269 --- /dev/null +++ b/lib/rubygems/deprecate.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module Gem + ## + # Provides 3 methods for declaring when something is going away. + # + # <tt>deprecate(name, repl, year, month)</tt>: + # Indicate something may be removed on/after a certain date. + # + # <tt>rubygems_deprecate(name, replacement=:none)</tt>: + # Indicate something will be removed in the next major RubyGems version, + # and (optionally) a replacement for it. + # + # +rubygems_deprecate_command+: + # Indicate a RubyGems command (in +lib/rubygems/commands/*.rb+) will be + # removed in the next RubyGems version. + # + # Also provides +skip_during+ for temporarily turning off deprecation warnings. + # This is intended to be used in the test suite, so deprecation warnings + # don't cause test failures if you need to make sure stderr is otherwise empty. + # + # + # Example usage of +deprecate+ and +rubygems_deprecate+: + # + # class Legacy + # def self.some_class_method + # # ... + # end + # + # def some_instance_method + # # ... + # end + # + # def some_old_method + # # ... + # end + # + # extend Gem::Deprecate + # deprecate :some_instance_method, "X.z", 2011, 4 + # rubygems_deprecate :some_old_method, "Modern#some_new_method" + # + # class << self + # extend Gem::Deprecate + # deprecate :some_class_method, :none, 2011, 4 + # end + # end + # + # + # Example usage of +rubygems_deprecate_command+: + # + # class Gem::Commands::QueryCommand < Gem::Command + # extend Gem::Deprecate + # rubygems_deprecate_command + # + # # ... + # end + # + # + # Example usage of +skip_during+: + # + # class TestSomething < Gem::Testcase + # def test_some_thing_with_deprecations + # Gem::Deprecate.skip_during do + # actual_stdout, actual_stderr = capture_output do + # Gem.something_deprecated + # end + # assert_empty actual_stdout + # assert_equal(expected, actual_stderr) + # end + # end + # end + + module Deprecate + def self.skip # :nodoc: + @skip ||= false + end + + def self.skip=(v) # :nodoc: + @skip = v + end + + ## + # Temporarily turn off warnings. Intended for tests only. + + def skip_during + original = Gem::Deprecate.skip + Gem::Deprecate.skip = true + yield + ensure + Gem::Deprecate.skip = original + end + + def self.next_rubygems_major_version # :nodoc: + Gem::Version.new(Gem.rubygems_version.segments.first).bump + end + + ## + # Simple deprecation method that deprecates +name+ by wrapping it up + # in a dummy method. It warns on each call to the dummy method + # telling the user of +repl+ (unless +repl+ is :none) and the + # year/month that it is planned to go away. + + def deprecate(name, repl, year, month) + class_eval do + old = "_deprecated_#{name}" + alias_method old, name + define_method name do |*args, &block| + klass = is_a? Module + target = klass ? "#{self}." : "#{self.class}#" + msg = [ + "NOTE: #{target}#{name} is deprecated", + repl == :none ? " with no replacement" : "; use #{repl} instead", + format(". It will be removed on or after %4d-%02d.", year, month), + "\n#{target}#{name} called from #{Gem.location_of_caller.join(":")}", + ] + warn "#{msg.join}." unless Gem::Deprecate.skip + send old, *args, &block + end + ruby2_keywords name if respond_to?(:ruby2_keywords, true) + end + end + + ## + # Simple deprecation method that deprecates +name+ by wrapping it up + # in a dummy method. It warns on each call to the dummy method + # telling the user of +repl+ (unless +repl+ is :none) and the + # Rubygems version that it is planned to go away. + + def rubygems_deprecate(name, replacement = :none, version = nil) + class_eval do + old = "_deprecated_#{name}" + alias_method old, name + define_method name do |*args, &block| + klass = is_a? Module + target = klass ? "#{self}." : "#{self.class}#" + version ||= Gem::Deprecate.next_rubygems_major_version + msg = [ + "NOTE: #{target}#{name} is deprecated", + replacement == :none ? " with no replacement" : "; use #{replacement} instead", + ". It will be removed in Rubygems #{version}", + "\n#{target}#{name} called from #{Gem.location_of_caller.join(":")}", + ] + warn "#{msg.join}." unless Gem::Deprecate.skip + send old, *args, &block + end + ruby2_keywords name if respond_to?(:ruby2_keywords, true) + end + end + + # Deprecation method to deprecate Rubygems commands + def rubygems_deprecate_command(version = nil) + class_eval do + define_method "deprecated?" do + true + end + + define_method "deprecation_warning" do + version ||= Gem::Deprecate.next_rubygems_major_version + msg = [ + "#{command} command is deprecated", + ". It will be removed in Rubygems #{version}.\n", + ] + + alert_warning msg.join.to_s unless Gem::Deprecate.skip + end + end + end + + module_function :rubygems_deprecate, :rubygems_deprecate_command, :skip_during + end +end diff --git a/lib/rubygems/doctor.rb b/lib/rubygems/doctor.rb new file mode 100644 index 0000000000..4f26260d83 --- /dev/null +++ b/lib/rubygems/doctor.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require_relative "../rubygems" +require_relative "user_interaction" + +## +# Cleans up after a partially-failed uninstall or for an invalid +# Gem::Specification. +# +# If a specification was removed by hand this will remove any remaining files. +# +# If a corrupt specification was installed this will clean up warnings by +# removing the bogus specification. + +class Gem::Doctor + include Gem::UserInteraction + + ## + # Maps a gem subdirectory to the files that are expected to exist in the + # subdirectory. + + REPOSITORY_EXTENSION_MAP = [ # :nodoc: + ["specifications", ".gemspec"], + ["build_info", ".info"], + ["cache", ".gem"], + ["doc", ""], + ["extensions", ""], + ["gems", ""], + ["plugins", ""], + ].freeze + + missing = + Gem::REPOSITORY_SUBDIRECTORIES.sort - + REPOSITORY_EXTENSION_MAP.map {|(k,_)| k }.sort + + raise "Update REPOSITORY_EXTENSION_MAP, missing: #{missing.join ", "}" unless + missing.empty? + + ## + # Creates a new Gem::Doctor that will clean up +gem_repository+. Only one + # gem repository may be cleaned at a time. + # + # If +dry_run+ is true no files or directories will be removed. + + def initialize(gem_repository, dry_run = false) + @gem_repository = gem_repository + @dry_run = dry_run + + @installed_specs = nil + end + + ## + # Specs installed in this gem repository + + def installed_specs # :nodoc: + @installed_specs ||= Gem::Specification.map(&:full_name) + end + + ## + # Are we doctoring a gem repository? + + def gem_repository? + !installed_specs.empty? + end + + ## + # Cleans up uninstalled files and invalid gem specifications + + def doctor + @orig_home = Gem.dir + @orig_path = Gem.path + + say "Checking #{@gem_repository}" + + Gem.use_paths @gem_repository.to_s + + unless gem_repository? + say "This directory does not appear to be a RubyGems repository, " \ + "skipping" + say + return + end + + doctor_children + + say + ensure + Gem.use_paths @orig_home, *@orig_path + end + + ## + # Cleans up children of this gem repository + + def doctor_children # :nodoc: + REPOSITORY_EXTENSION_MAP.each do |sub_directory, extension| + doctor_child sub_directory, extension + end + end + + ## + # Removes files in +sub_directory+ with +extension+ + + def doctor_child(sub_directory, extension) # :nodoc: + directory = File.join(@gem_repository, sub_directory) + + Dir.entries(directory).sort.each do |ent| + next if [".", ".."].include?(ent) + + child = File.join(directory, ent) + next unless File.exist?(child) + + basename = File.basename(child, extension) + next if installed_specs.include? basename + next if /^rubygems-\d/.match?(basename) + next if sub_directory == "specifications" && basename == "default" + next if sub_directory == "plugins" && Gem.plugin_suffix_regexp =~ basename + + type = File.directory?(child) ? "directory" : "file" + + action = if @dry_run + "Extra" + else + FileUtils.rm_r(child) + "Removed" + end + + say "#{action} #{type} #{sub_directory}/#{File.basename(child)}" + end + rescue Errno::ENOENT + # ignore + end +end diff --git a/lib/rubygems/errors.rb b/lib/rubygems/errors.rb new file mode 100644 index 0000000000..4bbc5217e0 --- /dev/null +++ b/lib/rubygems/errors.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +#-- +# This file contains all the various exceptions and other errors that are used +# inside of RubyGems. +# +# DOC: Confirm _all_ +#++ + +module Gem + ## + # Raised when RubyGems is unable to load or activate a gem. Contains the + # name and version requirements of the gem that either conflicts with + # already activated gems or that RubyGems is otherwise unable to activate. + + class LoadError < ::LoadError + # Name of gem + attr_accessor :name + + # Version requirement of gem + attr_accessor :requirement + end + + ## + # Raised when trying to activate a gem, and that gem does not exist on the + # system. Instead of rescuing from this class, make sure to rescue from the + # superclass Gem::LoadError to catch all types of load errors. + class MissingSpecError < Gem::LoadError + def initialize(name, requirement, extra_message = nil) + @name = name + @requirement = requirement + @extra_message = extra_message + super(message) + end + + def message # :nodoc: + build_message + + "Checked in 'GEM_PATH=#{Gem.path.join(File::PATH_SEPARATOR)}' #{@extra_message}, execute `gem env` for more information" + end + + private + + def build_message + total = Gem::Specification.stubs.size + "Could not find '#{name}' (#{requirement}) among #{total} total gem(s)\n" + end + end + + ## + # Raised when trying to activate a gem, and the gem exists on the system, but + # not the requested version. Instead of rescuing from this class, make sure to + # rescue from the superclass Gem::LoadError to catch all types of load errors. + class MissingSpecVersionError < MissingSpecError + attr_reader :specs + + def initialize(name, requirement, specs) + @specs = specs + super(name, requirement) + end + + private + + def build_message + names = specs.map(&:full_name) + "Could not find '#{name}' (#{requirement}) - did find: [#{names.join ","}]\n" + end + end + + # Raised when there are conflicting gem specs loaded + + class ConflictError < LoadError + ## + # A Hash mapping conflicting specifications to the dependencies that + # caused the conflict + + attr_reader :conflicts + + ## + # The specification that had the conflict + + attr_reader :target + + def initialize(target, conflicts) + @target = target + @conflicts = conflicts + @name = target.name + + reason = conflicts.map do |act, dependencies| + "#{act.full_name} conflicts with #{dependencies.join(", ")}" + end.join ", " + + # TODO: improve message by saying who activated `con` + + super("Unable to activate #{target.full_name}, because #{reason}") + end + end + + class ErrorReason; end + + # Generated when trying to lookup a gem to indicate that the gem + # was found, but that it isn't usable on the current platform. + # + # fetch and install read these and report them to the user to aid + # in figuring out why a gem couldn't be installed. + # + class PlatformMismatch < ErrorReason + ## + # the name of the gem + attr_reader :name + + ## + # the version + attr_reader :version + + ## + # The platforms that are mismatched + attr_reader :platforms + + def initialize(name, version) + @name = name + @version = version + @platforms = [] + end + + ## + # append a platform to the list of mismatched platforms. + # + # Platforms are added via this instead of injected via the constructor + # so that we can loop over a list of mismatches and just add them rather + # than perform some kind of calculation mismatch summary before creation. + def add_platform(platform) + @platforms << platform + end + + ## + # A wordy description of the error. + def wordy + format("Found %s (%s), but was for platform%s %s", @name, @version, @platforms.size == 1 ? "" : "s", @platforms.join(" ,")) + end + end + + ## + # An error that indicates we weren't able to fetch some + # data from a source + + class SourceFetchProblem < ErrorReason + ## + # Creates a new SourceFetchProblem for the given +source+ and +error+. + + def initialize(source, error) + @source = source + @error = error + end + + ## + # The source that had the fetch problem. + + attr_reader :source + + ## + # The fetch error which is an Exception subclass. + + attr_reader :error + + ## + # An English description of the error. + + def wordy + "Unable to download data from #{Gem::Uri.redact(@source.uri)} - #{@error.message}" + end + + ## + # The "exception" alias allows you to call raise on a SourceFetchProblem. + + alias_method :exception, :error + end +end diff --git a/lib/rubygems/exceptions.rb b/lib/rubygems/exceptions.rb new file mode 100644 index 0000000000..e00a70c662 --- /dev/null +++ b/lib/rubygems/exceptions.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require_relative "unknown_command_spell_checker" + +## +# Base exception class for RubyGems. All exception raised by RubyGems are a +# subclass of this one. +class Gem::Exception < RuntimeError; end + +class Gem::CommandLineError < Gem::Exception; end + +class Gem::UnknownCommandError < Gem::Exception + attr_reader :unknown_command + + def initialize(unknown_command) + self.class.attach_correctable + + @unknown_command = unknown_command + super("Unknown command #{unknown_command}") + end + + def self.attach_correctable + return if method_defined?(:corrections) + + if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) + DidYouMean.correct_error(Gem::UnknownCommandError, Gem::UnknownCommandSpellChecker) + end + end +end + +class Gem::DependencyError < Gem::Exception; end + +class Gem::DependencyRemovalException < Gem::Exception; end + +## +# Raised by Gem::Resolver when dependency resolution fails. + +class Gem::DependencyResolutionError < Gem::DependencyError + def initialize(conflict) + @explanation = conflict.explanation + super @explanation + end + + def explanation + @explanation + end + + def conflict + nil + end + + def conflicting_dependencies + [] + end +end + +## +# Raised when attempting to uninstall a gem that isn't in GEM_HOME. + +class Gem::GemNotInHomeException < Gem::Exception + attr_accessor :spec +end + +### +# Raised when removing a gem with the uninstall command fails + +class Gem::UninstallError < Gem::Exception + attr_accessor :spec +end + +class Gem::DocumentError < Gem::Exception; end + +## +# Potentially raised when a specification is validated. +class Gem::EndOfYAMLException < Gem::Exception; end + +## +# Signals that a file permission error is preventing the user from +# operating on the given directory. + +class Gem::FilePermissionError < Gem::Exception + attr_reader :directory + + def initialize(directory) + @directory = directory + + super "You don't have write permissions for the #{directory} directory." + end +end + +## +# Used to raise parsing and loading errors +class Gem::FormatException < Gem::Exception + attr_accessor :file_path +end + +class Gem::GemNotFoundException < Gem::Exception; end + +class Gem::SpecificGemNotFoundException < Gem::GemNotFoundException + ## + # Creates a new SpecificGemNotFoundException for a gem with the given +name+ + # and +version+. Any +errors+ encountered when attempting to find the gem + # are also stored. + + def initialize(name, version, errors = nil) + super "Could not find a valid gem '#{name}' (#{version}) locally or in a repository" + + @name = name + @version = version + @errors = errors + end + + ## + # The name of the gem that could not be found. + + attr_reader :name + + ## + # The version of the gem that could not be found. + + attr_reader :version + + ## + # Errors encountered attempting to find the gem. + + attr_reader :errors +end + +Gem.deprecate_constant :SpecificGemNotFoundException + +class Gem::InstallError < Gem::Exception; end + +class Gem::RuntimeRequirementNotMetError < Gem::InstallError + attr_accessor :suggestion + def message + [suggestion, super].compact.join("\n\t") + end +end + +## +# Potentially raised when a specification is validated. +class Gem::InvalidSpecificationException < Gem::Exception; end + +class Gem::OperationNotSupportedError < Gem::Exception; end + +## +# Signals that a remote operation cannot be conducted, probably due to not +# being connected (or just not finding host). +#-- +# TODO: create a method that tests connection to the preferred gems server. +# All code dealing with remote operations will want this. Failure in that +# method should raise this error. +class Gem::RemoteError < Gem::Exception; end + +class Gem::RemoteInstallationCancelled < Gem::Exception; end + +class Gem::RemoteInstallationSkipped < Gem::Exception; end + +## +# Represents an error communicating via HTTP. +class Gem::RemoteSourceException < Gem::Exception; end + +## +# Raised when a gem dependencies file specifies a ruby version that does not +# match the current version. + +class Gem::RubyVersionMismatch < Gem::Exception; end + +## +# Raised by Gem::Validator when something is not right in a gem. + +class Gem::VerificationError < Gem::Exception; end + +## +# Raised by Gem::WebauthnListener when an error occurs during security +# device verification. + +class Gem::WebauthnVerificationError < Gem::Exception + def initialize(message) + super "Security device verification failed: #{message}" + end +end + +## +# Raised to indicate that a system exit should occur with the specified +# exit_code + +class Gem::SystemExitException < SystemExit + ## + # The exit code for the process + + alias_method :exit_code, :status + + ## + # Creates a new SystemExitException with the given +exit_code+ + + def initialize(exit_code) + super exit_code, "Exiting RubyGems with exit_code #{exit_code}" + end +end + +## +# Raised by Resolver when a dependency requests a gem for which +# there is no spec. + +class Gem::UnsatisfiableDependencyError < Gem::DependencyError + ## + # The unsatisfiable dependency. This is a + # Gem::Resolver::DependencyRequest, not a Gem::Dependency + + attr_reader :dependency + + ## + # Errors encountered which may have contributed to this exception + + attr_accessor :errors + + ## + # Creates a new UnsatisfiableDependencyError for the unsatisfiable + # Gem::Resolver::DependencyRequest +dep+ + + def initialize(dep, platform_mismatch = nil) + if platform_mismatch && !platform_mismatch.empty? + plats = platform_mismatch.map {|x| x.platform.to_s }.sort.uniq + super "Unable to resolve dependency: No match for '#{dep}' on this platform. Found: #{plats.join(", ")}" + else + if dep.explicit? + super "Unable to resolve dependency: user requested '#{dep}'" + else + super "Unable to resolve dependency: '#{dep.request_context}' requires '#{dep}'" + end + end + + @dependency = dep + @errors = [] + end + + ## + # The name of the unresolved dependency + + def name + @dependency.name + end + + ## + # The Requirement of the unresolved dependency (not Version). + + def version + @dependency.requirement + end +end diff --git a/lib/rubygems/ext.rb b/lib/rubygems/ext.rb new file mode 100644 index 0000000000..b5ca126a08 --- /dev/null +++ b/lib/rubygems/ext.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +## +# Classes for building C extensions live here. + +module Gem::Ext; end + +require_relative "ext/build_error" +require_relative "ext/builder" +require_relative "ext/configure_builder" +require_relative "ext/ext_conf_builder" +require_relative "ext/rake_builder" +require_relative "ext/cmake_builder" +require_relative "ext/cargo_builder" diff --git a/lib/rubygems/ext/build_error.rb b/lib/rubygems/ext/build_error.rb new file mode 100644 index 0000000000..0329c1eec3 --- /dev/null +++ b/lib/rubygems/ext/build_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +## +# Raised when there is an error while building extensions. + +require_relative "../exceptions" + +class Gem::Ext::BuildError < Gem::InstallError +end diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb new file mode 100644 index 0000000000..e00cf159da --- /dev/null +++ b/lib/rubygems/ext/builder.rb @@ -0,0 +1,271 @@ +# 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" + +class Gem::Ext::Builder + include Gem::UserInteraction + + class NoMakefileError < Gem::InstallError + end + + attr_accessor :build_args # :nodoc: + + def self.class_name + name =~ /Ext::(.*)Builder/ + $1.downcase + end + + def self.make(dest_path, results, make_dir = Dir.pwd, sitedir = nil, targets = ["clean", "", "install"], + target_rbconfig: Gem.target_rbconfig, n_jobs: nil) + unless File.exist? File.join(make_dir, "Makefile") + # No makefile exists, nothing to do. + raise NoMakefileError, "No Makefile found in #{make_dir}" + end + + # try to find make program from Ruby configure arguments first + target_rbconfig["configure_args"] =~ /with-make-prog\=(\w+)/ + make_program_name = ENV["MAKE"] || ENV["make"] || $1 + make_program_name ||= RUBY_PLATFORM.include?("mswin") ? "nmake" : "make" + make_program = shellsplit(make_program_name) + + is_nmake = /\bnmake/i.match?(make_program_name) + # The installation of the bundled gems is failed when DESTDIR is empty in mswin platform. + destdir = !is_nmake || ENV["DESTDIR"] && ENV["DESTDIR"] != "" ? format("DESTDIR=%s", ENV["DESTDIR"]) : "" + + # nmake doesn't support parallel build + unless is_nmake + have_make_arguments = make_program.size > 1 + + if !have_make_arguments && !ENV["MAKEFLAGS"] && n_jobs + make_program << "-j#{n_jobs}" + end + end + + env = [destdir] + + if sitedir + env << format("sitearchdir=%s", sitedir) + env << format("sitelibdir=%s", sitedir) + end + + targets.each do |target| + # Pass DESTDIR via command line to override what's in MAKEFLAGS + cmd = [ + *make_program, + *env, + target, + ].reject(&:empty?) + begin + run(cmd, results, "make #{target}".rstrip, make_dir) + rescue Gem::InstallError + raise unless target == "clean" # ignore clean failure + end + end + end + + def self.ruby + # Gem.ruby is quoted if it contains whitespace + cmd = shellsplit(Gem.ruby) + + # This load_path is only needed when running rubygems test without a proper installation. + # Prepending it in a normal installation will cause problem with order of $LOAD_PATH. + # Therefore only add load_path if it is not present in the default $LOAD_PATH. + load_path = File.expand_path("../..", __dir__) + case load_path + when RbConfig::CONFIG["sitelibdir"], RbConfig::CONFIG["vendorlibdir"], RbConfig::CONFIG["rubylibdir"] + cmd + else + cmd << "-I#{load_path}" + end + end + + def self.run(command, results, command_name = nil, dir = Dir.pwd, env = {}) + verbose = Gem.configuration.really_verbose + + begin + rubygems_gemdeps = ENV["RUBYGEMS_GEMDEPS"] + ENV["RUBYGEMS_GEMDEPS"] = nil + if verbose + puts("current directory: #{dir}") + p(command) + end + results << "current directory: #{dir}" + results << shelljoin(command) + + require "open3" + # Set $SOURCE_DATE_EPOCH for the subprocess. + build_env = { "SOURCE_DATE_EPOCH" => Gem.source_date_epoch_string }.merge(env) + output, status = begin + Open3.popen2e(build_env, *command, chdir: dir) do |stdin, stdouterr, wait_thread| + stdin.close + output = String.new + while line = stdouterr.gets + output << line + if verbose + print line + end + end + [output, wait_thread.value] + end + rescue StandardError => error + raise Gem::InstallError, "#{command_name || class_name} failed#{error.message}" + end + unless verbose + results << output + end + ensure + ENV["RUBYGEMS_GEMDEPS"] = rubygems_gemdeps + end + + unless status.success? + results << "Building has failed. See above output for more information on the failure." if verbose + end + + yield(status, results) if block_given? + + unless status.success? + exit_reason = + if status.exited? + ", exit code #{status.exitstatus}" + elsif status.signaled? + ", uncaught signal #{status.termsig}" + end + + raise Gem::InstallError, "#{command_name || class_name} failed#{exit_reason}" + end + end + + def self.shellsplit(command) + require "shellwords" + + Shellwords.split(command) + end + + def self.shelljoin(command) + require "shellwords" + + Shellwords.join(command) + end + + ## + # Creates a new extension builder for +spec+. If the +spec+ does not yet + # have build arguments, saved, set +build_args+ which is an ARGV-style + # array. + + def initialize(spec, build_args = spec.build_args, target_rbconfig = Gem.target_rbconfig, build_jobs = nil) + @spec = spec + @build_args = build_args + @gem_dir = spec.full_gem_path + @target_rbconfig = target_rbconfig + @build_jobs = build_jobs + end + + ## + # Chooses the extension builder class for +extension+ + + def builder_for(extension) # :nodoc: + case extension + when /extconf/ then + Gem::Ext::ExtConfBuilder + when /configure/ then + Gem::Ext::ConfigureBuilder + when /rakefile/i, /mkrf_conf/i then + Gem::Ext::RakeBuilder + when /CMakeLists.txt/ then + Gem::Ext::CmakeBuilder.new + when /Cargo.toml/ then + Gem::Ext::CargoBuilder.new + else + build_error("No builder for extension '#{extension}'") + end + end + + ## + # Logs the build +output+, then raises Gem::Ext::BuildError. + + def build_error(output, backtrace = nil) # :nodoc: + gem_make_out = write_gem_make_out output + + message = <<-EOF +ERROR: Failed to build gem native extension. + + #{output} + +Gem files will remain installed in #{@gem_dir} for inspection. +Results logged to #{gem_make_out} +EOF + + raise Gem::Ext::BuildError, message, backtrace + end + + def build_extension(extension, dest_path) # :nodoc: + results = [] + + builder = builder_for(extension) + + extension_dir = + File.expand_path File.join(@gem_dir, File.dirname(extension)) + lib_dir = File.join @spec.full_gem_path, @spec.raw_require_paths.first + + begin + FileUtils.mkdir_p dest_path + + results = builder.build(extension, dest_path, + results, @build_args, lib_dir, extension_dir, @target_rbconfig, n_jobs: @build_jobs) + + verbose { results.join("\n") } + + write_gem_make_out results.join "\n" + rescue StandardError => e + results << e.message + build_error(results.join("\n"), $@) + end + end + + ## + # Builds extensions. Valid types of extensions are extconf.rb files, + # configure scripts and rakefiles or mkrf_conf files. + + def build_extensions + return if @spec.extensions.empty? + + if @build_args.empty? + say "Building native extensions. This could take a while..." + else + say "Building native extensions with: '#{@build_args.join " "}'" + say "This could take a while..." + end + + dest_path = @spec.extension_dir + + require "fileutils" + FileUtils.rm_f @spec.gem_build_complete_path + + @spec.extensions.each do |extension| + build_extension extension, dest_path + end + + FileUtils.touch @spec.gem_build_complete_path + end + + ## + # Writes +output+ to gem_make.out in the extension install directory. + + def write_gem_make_out(output) # :nodoc: + destination = File.join @spec.extension_dir, "gem_make.out" + + FileUtils.mkdir_p @spec.extension_dir + + File.open destination, "wb" do |io| + io.puts output + end + + destination + end +end diff --git a/lib/rubygems/ext/cargo_builder.rb b/lib/rubygems/ext/cargo_builder.rb new file mode 100644 index 0000000000..516459dd60 --- /dev/null +++ b/lib/rubygems/ext/cargo_builder.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +# This class is used by rubygems to build Rust extensions. It is a thin-wrapper +# over the `cargo rustc` command which takes care of building Rust code in a way +# that Ruby can use. +class Gem::Ext::CargoBuilder < Gem::Ext::Builder + attr_accessor :spec, :runner, :profile + + def initialize + require_relative "../command" + require_relative "cargo_builder/link_flag_converter" + + @runner = self.class.method(:run) + @profile = :release + end + + def build(extension, dest_path, results, args = [], lib_dir = nil, cargo_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + require "tempfile" + require "fileutils" + + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for Rust extensions. Ignoring" + end + + # Where's the Cargo.toml of the crate we're building + cargo_toml = File.join(cargo_dir, "Cargo.toml") + # What's the crate's name + crate_name = cargo_crate_name(cargo_dir, cargo_toml, results) + + begin + # Create a tmp dir to do the build in + tmp_dest = Dir.mktmpdir(".gem.", cargo_dir) + + # Run the build + cmd = cargo_command(cargo_toml, tmp_dest, args, crate_name) + runner.call(cmd, results, "cargo", cargo_dir, build_env) + + # Where do we expect Cargo to write the compiled library + dylib_path = cargo_dylib_path(tmp_dest, crate_name) + + # Helpful error if we didn't find the compiled library + raise DylibNotFoundError, tmp_dest unless File.exist?(dylib_path) + + # Cargo and Ruby differ on how the library should be named, rename from + # what Cargo outputs to what Ruby expects + dlext_name = "#{crate_name}.#{makefile_config("DLEXT")}" + dlext_path = File.join(File.dirname(dylib_path), dlext_name) + FileUtils.cp(dylib_path, dlext_path) + + nesting = extension_nesting(extension) + + if Gem.install_extension_in_lib && lib_dir + nested_lib_dir = File.join(lib_dir, nesting) + FileUtils.mkdir_p nested_lib_dir + FileUtils.cp_r dlext_path, nested_lib_dir, remove_destination: true + end + + # move to final destination + nested_dest_path = File.join(dest_path, nesting) + FileUtils.mkdir_p nested_dest_path + FileUtils.cp_r dlext_path, nested_dest_path, remove_destination: true + ensure + # clean up intermediary build artifacts + FileUtils.rm_rf tmp_dest if tmp_dest + end + + results + end + + def build_env + build_env = rb_config_env + build_env["RUBY_STATIC"] = "true" if ruby_static? && ENV.key?("RUBY_STATIC") + cfg = "--cfg=rb_sys_gem --cfg=rubygems --cfg=rubygems_#{Gem::VERSION.tr(".", "_")}" + build_env["RUSTFLAGS"] = [ENV["RUSTFLAGS"], cfg].compact.join(" ") + build_env + end + + def cargo_command(cargo_toml, dest_path, args = [], crate_name = nil) + cmd = [] + cmd += [cargo, "rustc"] + cmd += ["--crate-type", "cdylib"] + cmd += ["--target", ENV["CARGO_BUILD_TARGET"]] if ENV["CARGO_BUILD_TARGET"] + cmd += ["--target-dir", dest_path] + cmd += ["--manifest-path", cargo_toml] + cmd += ["--lib"] + cmd += ["--profile", profile.to_s] + cmd += ["--locked"] + cmd += Gem::Command.build_args + cmd += args + cmd += ["--"] + cmd += [*cargo_rustc_args(dest_path, crate_name)] + cmd + end + + private + + def cargo + ENV.fetch("CARGO", "cargo") + end + + # returns the directory nesting of the extension, ignoring the first part, so + # "ext/foo/bar/Cargo.toml" becomes "foo/bar" + def extension_nesting(extension) + parts = extension.to_s.split(Regexp.union([File::SEPARATOR, File::ALT_SEPARATOR].compact)) + + parts = parts.each_with_object([]) do |segment, final| + next if segment == "." + if segment == ".." + raise Gem::InstallError, "extension outside of gem root" if final.empty? + next final.pop + end + final << segment + end + + File.join(parts[1...-1]) + end + + def rb_config_env + result = {} + RbConfig::CONFIG.each {|k, v| result["RBCONFIG_#{k}"] = v } + result + end + + def cargo_rustc_args(dest_dir, crate_name) + [ + *linker_args, + *mkmf_libpath, + *rustc_dynamic_linker_flags(dest_dir, crate_name), + *rustc_lib_flags(dest_dir), + *platform_specific_rustc_args(dest_dir), + ] + end + + def platform_specific_rustc_args(dest_dir, flags = []) + if mingw_target? + # On mingw platforms, mkmf adds libruby to the linker flags + flags += libruby_args(dest_dir) + + # Make sure ALSR is used on mingw + # see https://github.com/rust-lang/rust/pull/75406/files + flags += ["-C", "link-arg=-Wl,--dynamicbase"] + flags += ["-C", "link-arg=-Wl,--disable-auto-image-base"] + + # If the gem is installed on a host with build tools installed, but is + # run on one that isn't the missing libraries will cause the extension + # to fail on start. + flags += ["-C", "link-arg=-static-libgcc"] + elsif darwin_target? + # Ventura does not always have this flag enabled + flags += ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"] + end + + flags + end + + # We want to use the same linker that Ruby uses, so that the linker flags from + # mkmf work properly. + def linker_args + cc_flag = self.class.shellsplit(makefile_config("CC")) + # Avoid to ccache like tool from Rust build + # see https://github.com/ruby/rubygems/pull/8521#issuecomment-2689854359 + # ex. CC="ccache gcc" or CC="sccache clang --any --args" + cc_flag.shift if cc_flag.size >= 2 && !cc_flag[1].start_with?("-") + linker = cc_flag.shift + link_args = cc_flag.flat_map {|a| ["-C", "link-arg=#{a}"] } + + return mswin_link_args if linker == "cl" + + ["-C", "linker=#{linker}", *link_args] + end + + def mswin_link_args + args = [] + args += ["-l", makefile_config("LIBRUBYARG_SHARED").chomp(".lib")] + args += split_flags("LIBS").flat_map {|lib| ["-l", lib.chomp(".lib")] } + args += split_flags("LOCAL_LIBS").flat_map {|lib| ["-l", lib.chomp(".lib")] } + args + end + + def libruby_args(dest_dir) + libs = makefile_config(ruby_static? ? "LIBRUBYARG_STATIC" : "LIBRUBYARG_SHARED") + raw_libs = self.class.shellsplit(libs) + raw_libs.flat_map {|l| ldflag_to_link_modifier(l) } + end + + def ruby_static? + return true if %w[1 true].include?(ENV["RUBY_STATIC"]) + + makefile_config("ENABLE_SHARED") == "no" + end + + def cargo_dylib_path(dest_path, crate_name) + so_ext = RbConfig::CONFIG["SOEXT"] + prefix = so_ext == "dll" ? "" : "lib" + path_parts = [dest_path] + path_parts << ENV["CARGO_BUILD_TARGET"] if ENV["CARGO_BUILD_TARGET"] + path_parts += ["release", "#{prefix}#{crate_name}.#{so_ext}"] + File.join(*path_parts) + end + + def cargo_crate_name(cargo_dir, manifest_path, results) + require "open3" + Gem.load_yaml + + output, status = + begin + Open3.capture2e(cargo, "metadata", "--no-deps", "--format-version", "1", chdir: cargo_dir) + rescue StandardError => error + raise Gem::InstallError, "cargo metadata failed #{error.message}" + end + + unless status.success? + if Gem.configuration.really_verbose + puts output + else + results << output + end + + exit_reason = + if status.exited? + ", exit code #{status.exitstatus}" + elsif status.signaled? + ", uncaught signal #{status.termsig}" + end + + raise Gem::InstallError, "cargo metadata failed#{exit_reason}" + end + + # cargo metadata output is specified as json + require "json" + metadata = JSON.parse(output) + package = metadata["packages"].find {|pkg| normalize_path(pkg["manifest_path"]) == manifest_path } + unless package + found = metadata["packages"].map {|md| "#{md["name"]} at #{md["manifest_path"]}" } + raise Gem::InstallError, <<-EOF +failed to determine cargo package name + +looking for: #{manifest_path} + +found: +#{found.join("\n")} +EOF + end + package["name"].tr("-", "_") + end + + def normalize_path(path) + return path unless File::ALT_SEPARATOR + + path.tr(File::ALT_SEPARATOR, File::SEPARATOR) + end + + def rustc_dynamic_linker_flags(dest_dir, crate_name) + split_flags("DLDFLAGS"). + filter_map {|arg| maybe_resolve_ldflag_variable(arg, dest_dir, crate_name) }. + flat_map {|arg| ldflag_to_link_modifier(arg) } + end + + def rustc_lib_flags(dest_dir) + split_flags("LIBS").flat_map {|arg| ldflag_to_link_modifier(arg) } + end + + def split_flags(var) + self.class.shellsplit(RbConfig::CONFIG.fetch(var, "")) + end + + def ldflag_to_link_modifier(arg) + LinkFlagConverter.convert(arg) + end + + def msvc_target? + makefile_config("target_os").include?("msvc") + end + + def darwin_target? + makefile_config("target_os").include?("darwin") + end + + def mingw_target? + makefile_config("target_os").include?("mingw") + end + + def win_target? + target_platform = RbConfig::CONFIG["target_os"] + !!Gem::WIN_PATTERNS.find {|r| target_platform =~ r } + end + + # Interpolate substitution vars in the arg (i.e. $(DEFFILE)) + def maybe_resolve_ldflag_variable(input_arg, dest_dir, crate_name) + var_matches = input_arg.match(/\$\((\w+)\)/) + + return input_arg unless var_matches + + var_name = var_matches[1] + + return input_arg if var_name.nil? || var_name.chomp.empty? + + case var_name + # On windows, it is assumed that mkmf has setup an exports file for the + # extension, so we have to create one ourselves. + when "DEFFILE" + write_deffile(dest_dir, crate_name) + else + RbConfig::CONFIG[var_name] + end + end + + def write_deffile(dest_dir, crate_name) + deffile_path = File.join(dest_dir, "#{crate_name}-#{RbConfig::CONFIG["arch"]}.def") + export_prefix = makefile_config("EXPORT_PREFIX") || "" + + File.open(deffile_path, "w") do |f| + f.puts "EXPORTS" + f.puts "#{export_prefix.strip}Init_#{crate_name}" + end + + deffile_path + end + + # Corresponds to $(LIBPATH) in mkmf + def mkmf_libpath + ["-L", "native=#{makefile_config("libdir")}"] + end + + def makefile_config(var_name) + val = RbConfig::MAKEFILE_CONFIG[var_name] + + return unless val + + RbConfig.expand(val.dup) + end + + # Error raised when no cdylib artifact was created + class DylibNotFoundError < StandardError + def initialize(dir) + files = Dir.glob(File.join(dir, "**", "*")).map {|f| "- #{f}" }.join "\n" + + super <<~MSG + Dynamic library not found for Rust extension (in #{dir}) + + Make sure you set "crate-type" in Cargo.toml to "cdylib" + + Found files: + #{files} + MSG + end + end +end diff --git a/lib/rubygems/ext/cargo_builder/link_flag_converter.rb b/lib/rubygems/ext/cargo_builder/link_flag_converter.rb new file mode 100644 index 0000000000..e4d196cb10 --- /dev/null +++ b/lib/rubygems/ext/cargo_builder/link_flag_converter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Gem::Ext::CargoBuilder < Gem::Ext::Builder + # Converts Ruby link flags into something cargo understands + class LinkFlagConverter + FILTERED_PATTERNS = [ + /compress-debug-sections/, # Not supported by all linkers, and not required for Rust + ].freeze + + def self.convert(arg) + return [] if FILTERED_PATTERNS.any? {|p| p.match?(arg) } + + case arg.chomp + when /^-L\s*(.+)$/ + ["-L", "native=#{$1}"] + when /^--library=(\w+\S+)$/, /^-l\s*(\w+\S+)$/ + ["-l", $1] + when /^-l\s*([^:\s])+/ # -lfoo, but not -l:libfoo.a + ["-l", $1] + when /^-F\s*(.*)$/ + ["-l", "framework=#{$1}"] + else + ["-C", "link-args=#{arg}"] + end + end + end +end diff --git a/lib/rubygems/ext/cmake_builder.rb b/lib/rubygems/ext/cmake_builder.rb new file mode 100644 index 0000000000..e660ed558b --- /dev/null +++ b/lib/rubygems/ext/cmake_builder.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# This builder creates extensions defined using CMake. Its is invoked if a Gem's spec file +# sets the `extension` property to a string that contains `CMakeLists.txt`. +# +# In general, CMake projects are built in two steps: +# +# * configure +# * build +# +# The builder follow this convention. First it runs a configuration step and then it runs a build step. +# +# CMake projects can be quite configurable - it is likely you will want to specify options when +# installing a gem. To pass options to CMake specify them after `--` in the gem install command. For example: +# +# gem install <gem_name> -- --preset <preset_name> +# +# Note that options are ONLY sent to the configure step - it is not currently possible to specify +# options for the build step. If this becomes and issue then the CMake builder can be updated to +# support build options. +# +# Useful options to know are: +# +# -G to specify a generator (-G Ninja is recommended) +# -D<CMAKE_VARIABLE> to set a CMake variable (for example -DCMAKE_BUILD_TYPE=Release) +# --preset <preset_name> to use a preset +# +# If the Gem author provides presets, via CMakePresets.json file, you will likely want to use one of them. +# If not, you may wish to specify a generator. Ninja is recommended because it can build projects in parallel +# and thus much faster than building them serially like Make does. + +class Gem::Ext::CmakeBuilder < Gem::Ext::Builder + attr_accessor :runner, :profile + def initialize + @runner = self.class.method(:run) + @profile = :release + end + + def build(extension, dest_path, results, args = [], lib_dir = nil, cmake_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for CMake extensions. Ignoring" + end + + # Figure the build dir + build_dir = File.join(cmake_dir, "build") + + # Check if the gem defined presets + check_presets(cmake_dir, args, results) + + # Configure + configure(cmake_dir, build_dir, dest_path, args, results) + + # Compile + compile(cmake_dir, build_dir, args, results) + + results + end + + def configure(cmake_dir, build_dir, install_dir, args, results) + cmd = ["cmake", + cmake_dir, + "-B", + build_dir, + "-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=#{install_dir}", # Windows + "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=#{install_dir}", # Not Windows + *Gem::Command.build_args, + *args] + + runner.call(cmd, results, "cmake_configure", cmake_dir) + end + + def compile(cmake_dir, build_dir, args, results) + cmd = ["cmake", + "--build", + build_dir.to_s, + "--config", + @profile.to_s] + + runner.call(cmd, results, "cmake_compile", cmake_dir) + end + + private + + def check_presets(cmake_dir, args, results) + # Return if the user specified a preset + return unless args.grep(/--preset/i).empty? + + cmd = ["cmake", + "--list-presets"] + + presets = Array.new + begin + runner.call(cmd, presets, "cmake_presets", cmake_dir) + + # Remove the first two lines of the array which is the current_directory and the command + # that was run + presets = presets[2..].join + results << <<~EOS + The gem author provided a list of presets that can be used to build the gem. To use a preset specify it on the command line: + + gem install <gem_name> -- --preset <preset_name> + + #{presets} + EOS + rescue Gem::InstallError + # Do nothing, CMakePresets.json was not included in the Gem + end + end +end diff --git a/lib/rubygems/ext/configure_builder.rb b/lib/rubygems/ext/configure_builder.rb new file mode 100644 index 0000000000..230b214b3c --- /dev/null +++ b/lib/rubygems/ext/configure_builder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +class Gem::Ext::ConfigureBuilder < Gem::Ext::Builder + def self.build(extension, dest_path, results, args = [], lib_dir = nil, configure_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for configure-based extensions. Ignoring" + end + + unless File.exist?(File.join(configure_dir, "Makefile")) + cmd = ["sh", "./configure", "--prefix=#{dest_path}", *args] + + run cmd, results, class_name, configure_dir + end + + make dest_path, results, configure_dir, target_rbconfig: target_rbconfig, n_jobs: n_jobs + + results + end +end diff --git a/lib/rubygems/ext/ext_conf_builder.rb b/lib/rubygems/ext/ext_conf_builder.rb new file mode 100644 index 0000000000..822454355d --- /dev/null +++ b/lib/rubygems/ext/ext_conf_builder.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +class Gem::Ext::ExtConfBuilder < Gem::Ext::Builder + def self.build(extension, dest_path, results, args = [], lib_dir = nil, extension_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + require "fileutils" + require "tempfile" + + tmp_dest = Dir.mktmpdir(".gem.", extension_dir) + + # Some versions of `mktmpdir` return absolute paths, which will break make + # if the paths contain spaces. + # + # As such, we convert to a relative path. + tmp_dest_relative = get_relative_path(tmp_dest.clone, extension_dir) + + destdir = ENV["DESTDIR"] + + begin + cmd = ruby << File.basename(extension) + cmd << "--target-rbconfig=#{target_rbconfig.path}" if target_rbconfig.path + cmd.push(*args) + + run(cmd, results, class_name, extension_dir) do |s, r| + mkmf_log = File.join(extension_dir, "mkmf.log") + if File.exist? mkmf_log + unless s.success? + r << "To see why this extension failed to compile, please check" \ + " the mkmf.log which can be found here:\n" + r << " " + File.join(dest_path, "mkmf.log") + "\n" + end + FileUtils.mv mkmf_log, dest_path + end + end + + ENV["DESTDIR"] = nil + + make dest_path, results, extension_dir, tmp_dest_relative, target_rbconfig: target_rbconfig, n_jobs: n_jobs + + full_tmp_dest = File.join(extension_dir, tmp_dest_relative) + + is_cross_compiling = target_rbconfig["platform"] != RbConfig::CONFIG["platform"] + # Do not copy extension libraries by default when cross-compiling + # not to conflict with the one already built for the host platform. + if Gem.install_extension_in_lib && lib_dir && !is_cross_compiling + FileUtils.mkdir_p lib_dir + entries = Dir.entries(full_tmp_dest) - %w[. ..] + entries = entries.map {|entry| File.join full_tmp_dest, entry } + FileUtils.cp_r entries, lib_dir, remove_destination: true + end + + FileUtils::Entry_.new(full_tmp_dest).traverse do |ent| + destent = ent.class.new(dest_path, ent.rel) + destent.exist? || FileUtils.mv(ent.path, destent.path) + end + + make dest_path, results, extension_dir, tmp_dest_relative, ["clean"], target_rbconfig: target_rbconfig + ensure + ENV["DESTDIR"] = destdir + end + + results + rescue Gem::Ext::Builder::NoMakefileError => error + results << error.message + results << "Skipping make for #{extension} as no Makefile was found." + # We are good, do not re-raise the error. + ensure + FileUtils.rm_rf tmp_dest if tmp_dest + end + + def self.get_relative_path(path, base) + path[0..base.length - 1] = "." if path.start_with?(base) + path + end +end diff --git a/lib/rubygems/ext/rake_builder.rb b/lib/rubygems/ext/rake_builder.rb new file mode 100644 index 0000000000..d702d7f339 --- /dev/null +++ b/lib/rubygems/ext/rake_builder.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +class Gem::Ext::RakeBuilder < Gem::Ext::Builder + def self.build(extension, dest_path, results, args = [], lib_dir = nil, extension_dir = Dir.pwd, + target_rbconfig = Gem.target_rbconfig, n_jobs: nil) + if target_rbconfig.path + warn "--target-rbconfig is not yet supported for Rake extensions. Ignoring" + end + + if /mkrf_conf/i.match?(File.basename(extension)) + run([Gem.ruby, File.basename(extension), *args], results, class_name, extension_dir) + end + + rake = ENV["rake"] + + if rake + rake = shellsplit(rake) + else + begin + rake = ruby << "-rrubygems" << Gem.bin_path("rake", "rake") + rescue Gem::Exception + rake = [Gem.default_exec_format % "rake"] + end + end + + rake_args = ["RUBYARCHDIR=#{dest_path}", "RUBYLIBDIR=#{dest_path}", *args] + run(rake + rake_args, results, class_name, extension_dir) + + results + end +end diff --git a/lib/rubygems/gem_runner.rb b/lib/rubygems/gem_runner.rb new file mode 100644 index 0000000000..e60cebd0cb --- /dev/null +++ b/lib/rubygems/gem_runner.rb @@ -0,0 +1,88 @@ +# 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 "../rubygems" +require_relative "command_manager" + +## +# Run an instance of the gem program. +# +# Gem::GemRunner is only intended for internal use by RubyGems itself. It +# does not form any public API and may change at any time for any reason. +# +# If you would like to duplicate functionality of `gem` commands, use the +# classes they call directly. + +class Gem::GemRunner + def initialize + @command_manager_class = Gem::CommandManager + @config_file_class = Gem::ConfigFile + end + + ## + # Run the gem command with the following arguments. + + def run(args) + validate_encoding args + build_args = extract_build_args args + + do_configuration args + + begin + Gem.load_env_plugins + rescue StandardError + nil + end + Gem.load_plugins + + cmd = @command_manager_class.instance + + cmd.command_names.each do |command_name| + config_args = Gem.configuration[command_name] + config_args = case config_args + when String + config_args.split " " + else + Array(config_args) + end + Gem::Command.add_specific_extra_args command_name, config_args + end + + cmd.run Gem.configuration.args, build_args + end + + ## + # Separates the build arguments (those following <code>--</code>) from the + # other arguments in the list. + + def extract_build_args(args) # :nodoc: + return [] unless offset = args.index("--") + + build_args = args.slice!(offset...args.length) + + build_args.shift + + build_args + end + + private + + def validate_encoding(args) + invalid_arg = args.find {|arg| !arg.valid_encoding? } + + if invalid_arg + raise Gem::OptionParser::InvalidArgument.new("'#{invalid_arg.scrub}' has invalid encoding") + end + end + + def do_configuration(args) + Gem.configuration = @config_file_class.new(args) + Gem.use_paths Gem.configuration[:gemhome], Gem.configuration[:gempath] + Gem::Command.extra_args = Gem.configuration[:gem] + end +end diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb new file mode 100644 index 0000000000..9c22c14fad --- /dev/null +++ b/lib/rubygems/gemcutter_utilities.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require_relative "remote_fetcher" +require_relative "text" +require_relative "gemcutter_utilities/webauthn_listener" +require_relative "gemcutter_utilities/webauthn_poller" + +## +# Utility methods for using the RubyGems API. + +module Gem::GemcutterUtilities + ERROR_CODE = 1 + API_SCOPES = [:index_rubygems, :push_rubygem, :yank_rubygem, :add_owner, :remove_owner, :access_webhooks].freeze + EXCLUSIVELY_API_SCOPES = [:show_dashboard].freeze + + include Gem::Text + + attr_writer :host + attr_writer :scope + + ## + # Add the --key option + + def add_key_option + add_option("-k", "--key KEYNAME", Symbol, + "Use the given API key", + "from #{Gem.configuration.credentials_path}") do |value,options| + options[:key] = value + end + end + + ## + # Add the --otp option + + def add_otp_option + add_option("--otp CODE", + "Digit code for multifactor authentication", + "You can also use the environment variable GEM_HOST_OTP_CODE") do |value, options| + options[:otp] = value + end + end + + ## + # The API key from the command options or from the user's configuration. + + def api_key + if ENV["GEM_HOST_API_KEY"] + ENV["GEM_HOST_API_KEY"] + elsif options[:key] + verify_api_key options[:key] + elsif Gem.configuration.api_keys.key?(host) + Gem.configuration.api_keys[host] + else + Gem.configuration.rubygems_api_key + end + end + + ## + # The OTP code from the command options or from the user's configuration. + + def otp + options[:otp] || ENV["GEM_HOST_OTP_CODE"] + end + + def webauthn_enabled? + options[:webauthn] + end + + ## + # The host to connect to either from the RUBYGEMS_HOST environment variable + # or from the user's configuration + + def host + configured_host = Gem.host unless + Gem.configuration.disable_default_gem_server + + @host ||= + begin + env_rubygems_host = ENV["RUBYGEMS_HOST"] + env_rubygems_host = nil if env_rubygems_host&.empty? + + env_rubygems_host || configured_host + end + end + + ## + # Creates an RubyGems API to +host+ and +path+ with the given HTTP +method+. + # + # If +allowed_push_host+ metadata is present, then it will only allow that host. + + def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scope: nil, credentials: {}, &block) + require_relative "vendored_net_http" + + self.host = host if host + unless self.host + alert_error "You must specify a gem server" + terminate_interaction(ERROR_CODE) + end + + if allowed_push_host + allowed_host_uri = Gem::URI.parse(allowed_push_host) + host_uri = Gem::URI.parse(self.host) + + unless (host_uri.scheme == allowed_host_uri.scheme) && (host_uri.host == allowed_host_uri.host) + alert_error "#{self.host.inspect} is not allowed by the gemspec, which only allows #{allowed_push_host.inspect}" + terminate_interaction(ERROR_CODE) + end + end + + uri = Gem::URI.parse "#{self.host}/#{path}" + response = request_with_otp(method, uri, &block) + + if mfa_unauthorized?(response) + fetch_otp(credentials) + response = request_with_otp(method, uri, &block) + end + + if api_key_forbidden?(response) + update_scope(scope) + request_with_otp(method, uri, &block) + else + response + end + end + + def mfa_unauthorized?(response) + response.is_a?(Gem::Net::HTTPUnauthorized) && response.body.start_with?("You have enabled multifactor authentication") + end + + def update_scope(scope) + sign_in_host = host + pretty_host = pretty_host(sign_in_host) + update_scope_params = { scope => true } + + say "The existing key doesn't have access of #{scope} on #{pretty_host}. Please sign in to update access." + + identifier = ask "Username/email: " + password = ask_for_password " Password: " + + response = rubygems_api_request(:put, "api/v1/api_key", + sign_in_host, scope: scope) do |request| + request.basic_auth identifier, password + request.body = Gem::URI.encode_www_form({ api_key: api_key }.merge(update_scope_params)) + end + + with_response response do |_resp| + say "Added #{scope} scope to the existing API key" + end + end + + ## + # Signs in with the RubyGems API at +sign_in_host+ and sets the rubygems API + # key. + + def sign_in(sign_in_host = nil, scope: nil) + sign_in_host ||= host + pretty_host = pretty_host(sign_in_host) + if api_key + say "You are already signed in on #{pretty_host}." + return + end + say "Enter your #{pretty_host} credentials." + say "Don't have an account yet? " \ + "Create one at #{sign_in_host}/sign_up" + + identifier = ask "Username/email: " + password = ask_for_password " Password: " + say "\n" + + key_name = get_key_name(scope) + scope_params = get_scope_params(scope) + profile = get_user_profile(identifier, password) + mfa_params = get_mfa_params(profile) + all_params = scope_params.merge(mfa_params) + warning = profile["warning"] + credentials = { identifier: identifier, password: password } + + say "#{warning}\n" if warning + + response = rubygems_api_request(:post, "api/v1/api_key", + sign_in_host, credentials: credentials, scope: scope) do |request| + request.basic_auth identifier, password + request.body = Gem::URI.encode_www_form({ name: key_name }.merge(all_params)) + end + + with_response response do |resp| + say "Signed in with API key: #{key_name}." + set_api_key host, resp.body + end + end + + ## + # Retrieves the pre-configured API key +key+ or terminates interaction with + # an error. + + def verify_api_key(key) + if Gem.configuration.api_keys.key? key + Gem.configuration.api_keys[key] + else + alert_error "No such API key. Please add it to your configuration (done automatically on initial `gem push`)." + terminate_interaction(ERROR_CODE) + end + end + + ## + # If +response+ is an HTTP Success (2XX) response, yields the response if a + # block was given or shows the response body to the user. + # + # If the response was not successful, shows an error to the user including + # the +error_prefix+ and the response body. If the response was a permanent redirect, + # shows an error to the user including the redirect location. + + def with_response(response, error_prefix = nil) + case response + when Gem::Net::HTTPSuccess then + if block_given? + yield response + else + say clean_text(response.body) + end + when Gem::Net::HTTPPermanentRedirect, Gem::Net::HTTPRedirection then + message = "The request has redirected permanently to #{response["location"]}. Please check your defined push host URL." + message = "#{error_prefix}: #{message}" if error_prefix + + say clean_text(message) + terminate_interaction(ERROR_CODE) + else + message = response.body + message = "#{error_prefix}: #{message}" if error_prefix + + say clean_text(message) + terminate_interaction(ERROR_CODE) + end + end + + ## + # Returns true when the user has enabled multifactor authentication from + # +response+ text and no otp provided by options. + + def set_api_key(host, key) + if default_host? + Gem.configuration.rubygems_api_key = key + else + Gem.configuration.set_api_key host, key + end + end + + private + + def request_with_otp(method, uri, &block) + request_method = Gem::Net::HTTP.const_get method.to_s.capitalize + + Gem::RemoteFetcher.fetcher.request(uri, request_method) do |req| + req["OTP"] = otp if otp + block.call(req) + end + ensure + options[:otp] = nil if webauthn_enabled? + end + + def fetch_otp(credentials) + options[:otp] = if webauthn_url = webauthn_verification_url(credentials) + server = TCPServer.new 0 + port = server.addr[1].to_s + + url_with_port = "#{webauthn_url}?port=#{port}" + say "You have enabled multi-factor authentication. Please visit the following URL to authenticate via security device. If you can't verify using WebAuthn but have OTP enabled, you can re-run the gem signin command with the `--otp [your_code]` option." + say "" + say url_with_port + say "" + + threads = [WebauthnListener.listener_thread(host, server), WebauthnPoller.poll_thread(options, host, webauthn_url, credentials)] + otp_thread = wait_for_otp_thread(*threads) + + threads.each(&:join) + + if error = otp_thread[:error] + alert_error error.message + terminate_interaction(1) + end + + options[:webauthn] = true + + say "You are verified with a security device. You may close the browser window." + otp_thread[:otp] + else + say "You have enabled multi-factor authentication. Please enter OTP code." + ask "Code: " + end + end + + def wait_for_otp_thread(*threads) + loop do + threads.each do |otp_thread| + return otp_thread unless otp_thread.alive? + end + sleep 0.1 + end + ensure + threads.each(&:exit) + end + + def webauthn_verification_url(credentials) + response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request| + if credentials.empty? + request.add_field "Authorization", api_key + else + request.basic_auth credentials[:identifier], credentials[:password] + end + end + response.is_a?(Gem::Net::HTTPSuccess) ? response.body : nil + end + + def pretty_host(host) + if default_host? + "RubyGems.org" + else + host + end + end + + def get_scope_params(scope) + scope_params = { index_rubygems: true, push_rubygem: true } + + if scope + scope_params = { scope => true } + else + say "The default access scope is:" + scope_params.each do |k, _v| + say " #{k}: y" + end + say "\n" + customise = ask_yes_no("Do you want to customise scopes?", false) + if customise + EXCLUSIVELY_API_SCOPES.each do |excl_scope| + selected = ask_yes_no("#{excl_scope} (exclusive scope, answering yes will not prompt for other scopes)", false) + next unless selected + + return { excl_scope => true } + end + + scope_params = {} + + API_SCOPES.each do |s| + selected = ask_yes_no(s.to_s, false) + scope_params[s] = true if selected + end + end + say "\n" + end + + scope_params + end + + def default_host? + host == Gem::DEFAULT_HOST + end + + def get_user_profile(identifier, password) + return {} unless default_host? + + response = rubygems_api_request(:get, "api/v1/profile/me.yaml") do |request| + request.basic_auth identifier, password + end + + with_response response do |resp| + Gem::ConfigFile.load_with_rubygems_config_hash(clean_text(resp.body)) + end + end + + def get_mfa_params(profile) + mfa_level = profile["mfa"] + params = {} + if ["ui_only", "ui_and_gem_signin"].include?(mfa_level) + selected = ask_yes_no("Would you like to enable MFA for this key? (strongly recommended)") + params["mfa"] = true if selected + end + params + end + + def get_key_name(scope) + hostname = Socket.gethostname || "unknown-host" + user = ENV["USER"] || ENV["USERNAME"] || "unknown-user" + ts = Time.now.strftime("%Y%m%d%H%M%S") + default_key_name = "#{hostname}-#{user}-#{ts}" + + key_name = ask "API Key name [#{default_key_name}]: " unless scope + if key_name.nil? || key_name.empty? + default_key_name + else + key_name + end + end + + def api_key_forbidden?(response) + response.is_a?(Gem::Net::HTTPForbidden) && response.body.start_with?("The API key doesn't have access") + end +end diff --git a/lib/rubygems/gemcutter_utilities/webauthn_listener.rb b/lib/rubygems/gemcutter_utilities/webauthn_listener.rb new file mode 100644 index 0000000000..3f56a077c9 --- /dev/null +++ b/lib/rubygems/gemcutter_utilities/webauthn_listener.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative "webauthn_listener/response" + +## +# The WebauthnListener class retrieves an OTP after a user successfully WebAuthns with the Gem host. +# An instance opens a socket using the TCPServer instance given and listens for a request from the Gem host. +# The request should be a GET request to the root path and contains the OTP code in the form +# of a query parameter `code`. The listener will return the code which will be used as the OTP for +# API requests. +# +# Types of responses sent by the listener after receiving a request: +# - 200 OK: OTP code was successfully retrieved +# - 204 No Content: If the request was an OPTIONS request +# - 400 Bad Request: If the request did not contain a query parameter `code` +# - 404 Not Found: The request was not to the root path +# - 405 Method Not Allowed: OTP code was not retrieved because the request was not a GET/OPTIONS request +# +# Example usage: +# +# thread = Gem::WebauthnListener.listener_thread("https://rubygems.example", server) +# thread.join +# otp = thread[:otp] +# error = thread[:error] +# + +module Gem::GemcutterUtilities + class WebauthnListener + attr_reader :host + + def initialize(host) + @host = host + end + + def self.listener_thread(host, server) + Thread.new do + thread = Thread.current + thread.abort_on_exception = true + thread.report_on_exception = false + thread[:otp] = new(host).wait_for_otp_code(server) + rescue Gem::WebauthnVerificationError => e + thread[:error] = e + ensure + server.close + end + end + + def wait_for_otp_code(server) + loop do + socket = server.accept + request_line = socket.gets + + method, req_uri, _protocol = request_line.split(" ") + req_uri = Gem::URI.parse(req_uri) + + responder = SocketResponder.new(socket) + + unless root_path?(req_uri) + responder.send(NotFoundResponse.for(host)) + raise Gem::WebauthnVerificationError, "Page at #{req_uri.path} not found." + end + + case method.upcase + when "OPTIONS" + responder.send(NoContentResponse.for(host)) + next # will be GET + when "GET" + if otp = parse_otp_from_uri(req_uri) + responder.send(OkResponse.for(host)) + return otp + end + responder.send(BadRequestResponse.for(host)) + raise Gem::WebauthnVerificationError, "Did not receive OTP from #{host}." + else + responder.send(MethodNotAllowedResponse.for(host)) + raise Gem::WebauthnVerificationError, "Invalid HTTP method #{method.upcase} received." + end + end + end + + private + + def root_path?(uri) + uri.path == "/" + end + + def parse_otp_from_uri(uri) + query = uri.query + return unless query && !query.empty? + + query.split("&") do |param| + key, value = param.split("=", 2) + if value && Gem::URI.decode_www_form_component(key) == "code" + return Gem::URI.decode_www_form_component(value) + end + end + + nil + end + + class SocketResponder + def initialize(socket) + @socket = socket + end + + def send(response) + @socket.print response.to_s + @socket.close + end + end + end +end diff --git a/lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb b/lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb new file mode 100644 index 0000000000..17baa64fff --- /dev/null +++ b/lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +## +# The WebauthnListener Response class is used by the WebauthnListener to create +# responses to be sent to the Gem host. It creates a Gem::Net::HTTPResponse instance +# when initialized and can be converted to the appropriate format to be sent by a socket using `to_s`. +# Gem::Net::HTTPResponse instances cannot be directly sent over a socket. +# +# Types of response classes: +# - OkResponse +# - NoContentResponse +# - BadRequestResponse +# - NotFoundResponse +# - MethodNotAllowedResponse +# +# Example usage: +# +# server = TCPServer.new(0) +# socket = server.accept +# +# response = OkResponse.for("https://rubygems.example") +# socket.print response.to_s +# socket.close +# + +module Gem::GemcutterUtilities + class WebauthnListener + class Response + attr_reader :http_response + + def self.for(host) + new(host) + end + + def initialize(host) + @host = host + + build_http_response + end + + def to_s + status_line = "HTTP/#{@http_response.http_version} #{@http_response.code} #{@http_response.message}\r\n" + headers = @http_response.to_hash.map {|header, value| "#{header}: #{value.join(", ")}\r\n" }.join + "\r\n" + body = @http_response.body ? "#{@http_response.body}\n" : "" + + status_line + headers + body + end + + private + + # Must be implemented in subclasses + def code + raise NotImplementedError + end + + def reason_phrase + raise NotImplementedError + end + + def body; end + + def build_http_response + response_class = Gem::Net::HTTPResponse::CODE_TO_OBJ[code.to_s] + @http_response = response_class.new("1.1", code, reason_phrase) + @http_response.instance_variable_set(:@read, true) + + add_connection_header + add_access_control_headers + add_body + end + + def add_connection_header + @http_response["connection"] = "close" + end + + def add_access_control_headers + @http_response["access-control-allow-origin"] = @host + @http_response["access-control-allow-methods"] = "POST" + @http_response["access-control-allow-headers"] = %w[Content-Type Authorization x-csrf-token] + end + + def add_body + return unless body + @http_response["content-type"] = "text/plain; charset=utf-8" + @http_response["content-length"] = body.bytesize + @http_response.instance_variable_set(:@body, body) + end + end + + class OkResponse < Response + private + + def code + 200 + end + + def reason_phrase + "OK" + end + + def body + "success" + end + end + + class NoContentResponse < Response + private + + def code + 204 + end + + def reason_phrase + "No Content" + end + end + + class BadRequestResponse < Response + private + + def code + 400 + end + + def reason_phrase + "Bad Request" + end + + def body + "missing code parameter" + end + end + + class NotFoundResponse < Response + private + + def code + 404 + end + + def reason_phrase + "Not Found" + end + end + + class MethodNotAllowedResponse < Response + private + + def code + 405 + end + + def reason_phrase + "Method Not Allowed" + end + + def add_access_control_headers + super + @http_response["allow"] = %w[GET OPTIONS] + end + end + end +end diff --git a/lib/rubygems/gemcutter_utilities/webauthn_poller.rb b/lib/rubygems/gemcutter_utilities/webauthn_poller.rb new file mode 100644 index 0000000000..fe3f163a88 --- /dev/null +++ b/lib/rubygems/gemcutter_utilities/webauthn_poller.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +## +# The WebauthnPoller class retrieves an OTP after a user successfully WebAuthns. An instance +# polls the Gem host for the OTP code. The polling request (api/v1/webauthn_verification/<webauthn_token>/status.json) +# is sent to the Gem host every 5 seconds and will timeout after 5 minutes. If the status field in the json response +# is "success", the code field will contain the OTP code. +# +# Example usage: +# +# thread = Gem::WebauthnPoller.poll_thread( +# {}, +# "RubyGems.org", +# "https://rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY", +# { email: "email@example.com", password: "password" } +# ) +# thread.join +# otp = thread[:otp] +# error = thread[:error] +# + +module Gem::GemcutterUtilities + class WebauthnPoller + include Gem::GemcutterUtilities + TIMEOUT_IN_SECONDS = 300 + + attr_reader :options, :host + + def initialize(options, host) + @options = options + @host = host + end + + def self.poll_thread(options, host, webauthn_url, credentials) + Thread.new do + thread = Thread.current + thread.abort_on_exception = true + thread.report_on_exception = false + thread[:otp] = new(options, host).poll_for_otp(webauthn_url, credentials) + rescue Gem::WebauthnVerificationError, Gem::Timeout::Error => e + thread[:error] = e + end + end + + def poll_for_otp(webauthn_url, credentials) + Gem::Timeout.timeout(TIMEOUT_IN_SECONDS) do + loop do + response = webauthn_verification_poll_response(webauthn_url, credentials) + raise Gem::WebauthnVerificationError, response.message unless response.is_a?(Gem::Net::HTTPSuccess) + + require "json" + parsed_response = JSON.parse(response.body) + case parsed_response["status"] + when "pending" + sleep 5 + when "success" + return parsed_response["code"] + else + raise Gem::WebauthnVerificationError, parsed_response.fetch("message", "Invalid response from server") + end + end + end + end + + private + + def webauthn_verification_poll_response(webauthn_url, credentials) + webauthn_token = %r{(?<=\/)[^\/]+(?=$)}.match(webauthn_url)[0] + rubygems_api_request(:get, "api/v1/webauthn_verification/#{webauthn_token}/status.json") do |request| + if credentials.empty? + request.add_field "Authorization", api_key + elsif credentials[:identifier] && credentials[:password] + request.basic_auth credentials[:identifier], credentials[:password] + else + raise Gem::WebauthnVerificationError, "Provided missing credentials" + end + end + end + end +end diff --git a/lib/rubygems/gemspec_helpers.rb b/lib/rubygems/gemspec_helpers.rb new file mode 100644 index 0000000000..2b20fcafa1 --- /dev/null +++ b/lib/rubygems/gemspec_helpers.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative "../rubygems" + +## +# Mixin methods for commands that work with gemspecs. + +module Gem::GemspecHelpers + def find_gemspec(glob = "*.gemspec") + gemspecs = Dir.glob(glob).sort + + if gemspecs.size > 1 + alert_error "Multiple gemspecs found: #{gemspecs}, please specify one" + terminate_interaction(1) + end + + gemspecs.first + end +end diff --git a/lib/rubygems/install_message.rb b/lib/rubygems/install_message.rb new file mode 100644 index 0000000000..a24e26b918 --- /dev/null +++ b/lib/rubygems/install_message.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "../rubygems" +require_relative "user_interaction" + +## +# A default post-install hook that displays "Successfully installed +# some_gem-1.0" + +Gem.post_install do |installer| + ui = Gem::DefaultUserInteraction.ui + ui.say "Successfully installed #{installer.spec.full_name}" +end diff --git a/lib/rubygems/install_update_options.rb b/lib/rubygems/install_update_options.rb new file mode 100644 index 0000000000..e8859cadaf --- /dev/null +++ b/lib/rubygems/install_update_options.rb @@ -0,0 +1,224 @@ +# 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 "../rubygems" +require_relative "security_option" + +## +# Mixin methods for install and update options for Gem::Commands + +module Gem::InstallUpdateOptions + include Gem::SecurityOption + + ## + # Add the install/update options to the option parser. + + def add_install_update_options + add_option(:"Install/Update", "-i", "--install-dir DIR", + "Gem repository directory to get installed", + "gems") do |value, options| + options[:install_dir] = File.expand_path(value) + end + + add_option(:"Install/Update", "-n", "--bindir DIR", + "Directory where executables will be", + "placed when the gem is installed") do |value, options| + options[:bin_dir] = File.expand_path(value) + end + + add_option(:"Install/Update", "-j", "--build-jobs VALUE", Integer, + "Specify the number of jobs to pass to `make` when installing", + "gems with native extensions.", + "Defaults to the number of processors.", + "This option is ignored on the mswin platform or", + "if the MAKEFLAGS environment variable is set.") do |value, options| + options[:build_jobs] = value + end + + add_option(:"Install/Update", "--document [TYPES]", Array, + "Generate documentation for installed gems", + "List the documentation types you wish to", + "generate. For example: rdoc,ri") do |value, options| + options[:document] = case value + when nil then %w[ri] + when false then [] + else value + end + end + + add_option(:"Install/Update", "--build-root DIR", + "Temporary installation root. Useful for building", + "packages. Do not use this when installing remote gems.") do |value, options| + options[:build_root] = File.expand_path(value) + end + + add_option(:"Install/Update", "--vendor", + "Install gem into the vendor directory.", + "Only for use by gem repackagers.") do |_value, options| + unless Gem.vendor_dir + raise Gem::OptionParser::InvalidOption.new "your platform is not supported" + end + + options[:vendor] = true + options[:install_dir] = Gem.vendor_dir + end + + add_option(:"Install/Update", "-N", "--no-document", + "Disable documentation generation") do |_value, options| + options[:document] = [] + end + + add_option(:"Install/Update", "-E", "--[no-]env-shebang", + "Rewrite the shebang line on installed", + "scripts to use /usr/bin/env") do |value, options| + options[:env_shebang] = value + end + + add_option(:"Install/Update", "-f", "--[no-]force", + "Force gem to install, bypassing dependency", + "checks") do |value, options| + options[:force] = value + end + + add_option(:"Install/Update", "-w", "--[no-]wrappers", + "Use bin wrappers for executables", + "Not available on dosish platforms") do |value, options| + options[:wrappers] = value + end + + add_security_option + + add_option(:"Install/Update", "--ignore-dependencies", + "Do not install any required dependent gems") do |value, options| + options[:ignore_dependencies] = value + end + + add_option(:"Install/Update", "--[no-]format-executable", + "Make installed executable names match Ruby.", + "If Ruby is ruby18, foo_exec will be", + "foo_exec18") do |value, options| + options[:format_executable] = value + end + + add_option(:"Install/Update", "--[no-]user-install", + "Install in user's home directory instead", + "of GEM_HOME.") do |value, options| + options[:user_install] = value + end + + add_option(:"Install/Update", "--development", + "Install additional development", + "dependencies") do |_value, options| + options[:development] = true + options[:dev_shallow] = true + end + + add_option(:"Install/Update", "--development-all", + "Install development dependencies for all", + "gems (including dev deps themselves)") do |_value, options| + options[:development] = true + options[:dev_shallow] = false + end + + add_option(:"Install/Update", "--conservative", + "Don't attempt to upgrade gems already", + "meeting version requirement") do |_value, options| + options[:conservative] = true + options[:minimal_deps] = true + end + + add_option(:"Install/Update", "--[no-]minimal-deps", + "Don't upgrade any dependencies that already", + "meet version requirements") do |value, options| + options[:minimal_deps] = value + end + + add_option(:"Install/Update", "--[no-]post-install-message", + "Print post install message") do |value, options| + options[:post_install_message] = value + end + + add_option(:"Install/Update", "-g", "--file [FILE]", + "Read from a gem dependencies API file and", + "install the listed gems") do |v,_o| + v ||= Gem::GEM_DEP_FILES.find do |file| + File.exist? file + end + + unless v + message = v ? v : "(tried #{Gem::GEM_DEP_FILES.join ", "})" + + raise Gem::OptionParser::InvalidArgument, + "cannot find gem dependencies file #{message}" + end + + options[:gemdeps] = v + end + + add_option(:"Install/Update", "--without GROUPS", Array, + "Omit the named groups (comma separated)", + "when installing from a gem dependencies", + "file") do |v,_o| + options[:without_groups].concat v.map(&:intern) + end + + add_option(:Deprecated, "--default", + "Add the gem's full specification to", + "specifications/default and extract only its bin") do |v,_o| + end + + add_option(:"Install/Update", "--explain", + "Rather than install the gems, indicate which would", + "be installed") do |v,_o| + options[:explain] = v + end + + add_option(:"Install/Update", "--[no-]lock", + "Create a lock file (when used with -g/--file)") do |v,_o| + options[:lock] = v + end + + add_option(:"Install/Update", "--[no-]suggestions", + "Suggest alternates when gems are not found") do |v,_o| + options[:suggest_alternate] = v + end + + add_option(:"Install/Update", "--target-rbconfig [FILE]", + "rbconfig.rb for the deployment target platform") do |v, _o| + Gem.set_target_rbconfig(v) + end + + add_option(:"Install/Update", "--[no-]build-extension", + "Build native extensions during installation.", + "Defaults to true") do |v, _o| + options[:build_extension] = v + end + + add_option(:"Install/Update", "--[no-]install-plugin", + "Install plugins during installation.", + "Defaults to true") do |v, _o| + options[:install_plugin] = v + end + end + + ## + # Default options for the gem install and update commands. + + def install_update_options + { + document: %w[ri], + } + end + + ## + # Default description for the gem install and update commands. + + def install_update_defaults_str + "--document=ri" + end +end diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb new file mode 100644 index 0000000000..15d6aac0fd --- /dev/null +++ b/lib/rubygems/installer.rb @@ -0,0 +1,1030 @@ +# 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 "installer_uninstaller_utils" +require_relative "exceptions" +require_relative "package" +require_relative "ext" +require_relative "user_interaction" + +## +# The installer installs the files contained in the .gem into the Gem.home. +# +# Gem::Installer does the work of putting files in all the right places on the +# filesystem including unpacking the gem into its gem dir, installing the +# gemspec in the specifications dir, storing the cached gem in the cache dir, +# and installing either wrappers or symlinks for executables. +# +# The installer invokes pre and post install hooks. Hooks can be added either +# through a rubygems_plugin.rb file in an installed gem or via a +# rubygems/defaults/#{RUBY_ENGINE}.rb or rubygems/defaults/operating_system.rb +# file. See Gem.pre_install and Gem.post_install for details. + +class Gem::Installer + ## + # Paths where env(1) might live. Some systems are broken and have it in + # /bin + + ENV_PATHS = %w[/usr/bin/env /bin/env].freeze + + ## + # Deprecated in favor of Gem::Ext::BuildError + + ExtensionBuildError = Gem::Ext::BuildError # :nodoc: + + include Gem::UserInteraction + + include Gem::InstallerUninstallerUtils + + ## + # The directory a gem's executables will be installed into + + attr_reader :bin_dir + + attr_reader :build_root # :nodoc: + + ## + # The gem repository the gem will be installed into + + attr_reader :gem_home + + ## + # The options passed when the Gem::Installer was instantiated. + + attr_reader :options + + ## + # The gem package instance. + + attr_reader :package + + class << self + ## + # Overrides the executable format. + # + # This is a sprintf format with a "%s" which will be replaced with the + # executable name. It is based off the ruby executable name's difference + # from "ruby". + + attr_writer :exec_format + + # Defaults to use Ruby's program prefix and suffix. + def exec_format + @exec_format ||= Gem.default_exec_format + end + end + + ## + # Construct an installer object for the gem file located at +path+ + + def self.at(path, options = {}) + security_policy = options[:security_policy] + package = Gem::Package.new path, security_policy + new package, options + end + + class FakePackage + attr_accessor :spec + + attr_accessor :dir_mode + attr_accessor :prog_mode + attr_accessor :data_mode + + def initialize(spec) + @spec = spec + end + + def extract_files(destination_dir, pattern = "*") + FileUtils.mkdir_p destination_dir + + spec.files.each do |file| + file = File.join destination_dir, file + next if File.exist? file + FileUtils.mkdir_p File.dirname(file) + File.open file, "w" do |fp| + fp.puts "# #{file}" + end + end + end + + def copy_to(path) + end + end + + ## + # Construct an installer object for an ephemeral gem (one where we don't + # actually have a .gem file, just a spec) + + def self.for_spec(spec, options = {}) + # FIXME: we should have a real Package class for this + new FakePackage.new(spec), options + end + + ## + # Constructs an Installer instance that will install the gem at +package+ which + # can either be a path or an instance of Gem::Package. +options+ is a Hash + # with the following keys: + # + # :bin_dir:: Where to put a bin wrapper if needed. + # :development:: Whether or not development dependencies should be installed. + # :env_shebang:: Use /usr/bin/env in bin wrappers. + # :force:: Overrides all version checks and security policy checks, except + # for a signed-gems-only policy. + # :format_executable:: Format the executable the same as the Ruby executable. + # If your Ruby is ruby18, foo_exec will be installed as + # foo_exec18. + # :ignore_dependencies:: Don't raise if a dependency is missing. + # :install_dir:: The directory to install the gem into. + # :security_policy:: Use the specified security policy. See Gem::Security + # :user_install:: Indicate that the gem should be unpacked into the users + # personal gem directory. + # :only_install_dir:: Only validate dependencies against what is in the + # install_dir + # :wrappers:: Install wrappers if true, symlinks if false. + # :build_args:: An Array of arguments to pass to the extension builder + # process. If not set, then Gem::Command.build_args is used + # :post_install_message:: Print gem post install message if true + + def initialize(package, options = {}) + require "fileutils" + + @options = options + @package = package + + process_options + + @package.dir_mode = options[:dir_mode] + @package.prog_mode = options[:prog_mode] + @package.data_mode = options[:data_mode] + end + + ## + # Checks if +filename+ exists in +@bin_dir+. + # + # If +@force+ is set +filename+ is overwritten. + # + # If +filename+ exists and it is a RubyGems wrapper for a different gem, then + # the user is consulted. + # + # If +filename+ exists and +@bin_dir+ is Gem.default_bindir (/usr/local) the + # user is consulted. + # + # Otherwise +filename+ is overwritten. + + def check_executable_overwrite(filename) # :nodoc: + return if @force + + generated_bin = File.join @bin_dir, formatted_program_filename(filename) + + return unless File.exist? generated_bin + + ruby_executable = false + existing = nil + + File.open generated_bin, "rb" do |io| + line = io.gets + shebang = /^#!.*ruby/o + + # TruffleRuby uses a bash prelude in default launchers + if load_relative_enabled? || RUBY_ENGINE == "truffleruby" + until line.nil? || shebang.match?(line) do + line = io.gets + end + end + + next unless line&.match?(shebang) + + io.gets # blankline + + # TODO: detect a specially formatted comment instead of trying + # to find a string inside Ruby code. + next unless io.gets&.include?("This file was generated by RubyGems") + + ruby_executable = true + existing = io.read.slice(/ + ^\s*( + Gem\.activate_and_load_bin_path\( | + load \s Gem\.activate_bin_path\( + ) + (['"])(.*?)(\2), + /x, 3) + end + + return if spec.name == existing + + # somebody has written to RubyGems' directory, overwrite, too bad + return if Gem.default_bindir != @bin_dir && !ruby_executable + + question = "#{spec.name}'s executable \"#{filename}\" conflicts with ".dup + + if ruby_executable + question << (existing || "an unknown executable") + + return if ask_yes_no "#{question}\nOverwrite the executable?", false + + conflict = "installed executable from #{existing}" + else + question << generated_bin + + return if ask_yes_no "#{question}\nOverwrite the executable?", false + + conflict = generated_bin + end + + raise Gem::InstallError, + "\"#{filename}\" from #{spec.name} conflicts with #{conflict}" + end + + ## + # Lazy accessor for the spec's gem directory. + + def gem_dir + @gem_dir ||= File.join(gem_home, "gems", spec.full_name) + end + + ## + # Lazy accessor for the installer's spec. + + def spec + @package.spec + end + + ## + # Installs the gem and returns a loaded Gem::Specification for the installed + # gem. + # + # The gem will be installed with the following structure: + # + # @gem_home/ + # cache/<gem-version>.gem #=> a cached copy of the installed gem + # gems/<gem-version>/... #=> extracted files + # specifications/<gem-version>.gemspec #=> the Gem::Specification + + def install + pre_install_checks + + run_pre_install_hooks + + # Set loaded_from to ensure extension_dir is correct + spec.loaded_from = spec_file + + # Completely remove any previous gem files + FileUtils.rm_rf gem_dir + FileUtils.rm_rf spec.extension_dir + + dir_mode = options[:dir_mode] + FileUtils.mkdir_p gem_dir, mode: dir_mode && 0o755 + + extract_files + + build_extensions + write_build_info_file + run_post_build_hooks + + generate_bin + if options[:install_plugin] == false + remove_stale_plugins + warn_skipped_plugins + else + generate_plugins + end + + write_spec + write_cache_file + + File.chmod(dir_mode, gem_dir) if dir_mode + + say spec.post_install_message if options[:post_install_message] && !spec.post_install_message.nil? + + Gem::Specification.add_spec(spec) unless @install_dir + + load_plugin unless options[:install_plugin] == false + + run_post_install_hooks + + spec + rescue Errno::EACCES => e + # Permission denied - /path/to/foo + raise Gem::FilePermissionError, e.message.split(" - ").last + end + + def run_pre_install_hooks # :nodoc: + Gem.pre_install_hooks.each do |hook| + next unless hook.call(self) == false + location = " at #{$1}" if hook.inspect =~ /[ @](.*:\d+)/ + + message = "pre-install hook#{location} failed for #{spec.full_name}" + raise Gem::InstallError, message + end + end + + def run_post_build_hooks # :nodoc: + Gem.post_build_hooks.each do |hook| + next unless hook.call(self) == false + FileUtils.rm_rf gem_dir + + location = " at #{$1}" if hook.inspect =~ /[ @](.*:\d+)/ + + message = "post-build hook#{location} failed for #{spec.full_name}" + raise Gem::InstallError, message + end + end + + def run_post_install_hooks # :nodoc: + Gem.post_install_hooks.each do |hook| + hook.call self + end + end + + ## + # + # Return an Array of Specifications contained within the gem_home + # we'll be installing into. + + def installed_specs + @installed_specs ||= begin + specs = [] + + Gem::Util.glob_files_in_dir("*.gemspec", File.join(gem_home, "specifications")).each do |path| + spec = Gem::Specification.load path + specs << spec if spec + end + + specs + end + end + + ## + # Ensure that the dependency is satisfied by the current installation of + # gem. If it is not an exception is raised. + # + # spec :: Gem::Specification + # dependency :: Gem::Dependency + + def ensure_dependency(spec, dependency) + unless installation_satisfies_dependency? dependency + raise Gem::InstallError, "#{spec.name} requires #{dependency}" + end + true + end + + ## + # True if the gems in the system satisfy +dependency+. + + def installation_satisfies_dependency?(dependency) + return true if @options[:development] && dependency.type == :development + return true if installed_specs.detect {|s| dependency.matches_spec? s } + return false if @only_install_dir + !dependency.matching_specs.empty? + end + + ## + # The location of the spec file that is installed. + # + + def spec_file + File.join gem_home, "specifications", "#{spec.full_name}.gemspec" + end + + def default_spec_dir + dir = File.join(gem_home, "specifications", "default") + FileUtils.mkdir_p dir + dir + end + + ## + # The location of the default spec file for default gems. + # + + def default_spec_file + File.join default_spec_dir, "#{spec.full_name}.gemspec" + end + + ## + # Writes the .gemspec specification (in Ruby) to the gem home's + # specifications directory. + + def write_spec + spec.installed_by_version = Gem.rubygems_version + + Gem.write_binary(spec_file, spec.to_ruby_for_cache) + end + + ## + # Writes the full .gemspec specification (in Ruby) to the gem home's + # specifications/default directory. + # + # In contrast to #write_spec, this keeps file lists, so the `gem contents` + # command works. + + def write_default_spec + Gem.write_binary(default_spec_file, spec.to_ruby) + end + + ## + # Creates windows .bat files for easy running of commands + + def generate_windows_script(filename, bindir) + if Gem.win_platform? + script_name = formatted_program_filename(filename) + ".bat" + script_path = File.join bindir, File.basename(script_name) + File.open script_path, "w" do |file| + file.puts windows_stub_script(bindir, filename) + end + + verbose script_path + end + end + + def generate_bin # :nodoc: + executables = spec.executables + return if executables.nil? || executables.empty? + + if @gem_home == Gem.user_dir + # If we get here, then one of the following likely happened: + # - `--user-install` was specified + # - `Gem::PathSupport#home` fell back to `Gem.user_dir` + # - GEM_HOME was manually set to `Gem.user_dir` + + check_that_user_bin_dir_is_in_path(executables) + end + + ensure_writable_dir @bin_dir + + executables.each do |filename| + bin_path = File.join gem_dir, spec.bindir, filename + next unless File.exist? bin_path + + mode = File.stat(bin_path).mode + dir_mode = options[:prog_mode] || (mode | 0o111) + + unless dir_mode == mode + File.chmod dir_mode, bin_path + end + + check_executable_overwrite filename + + if @wrappers + generate_bin_script filename, @bin_dir + else + generate_bin_symlink filename, @bin_dir + end + end + end + + def generate_plugins # :nodoc: + latest = Gem::Specification.latest_spec_for(spec.name) + return if latest && latest.version > spec.version + + ensure_writable_dir @plugins_dir + + if spec.plugins.empty? + remove_plugins_for(spec, @plugins_dir) + else + regenerate_plugins_for(spec, @plugins_dir) + end + rescue ArgumentError => e + raise e, "#{latest.name} #{latest.version} #{spec.name} #{spec.version}: #{e.message}" + end + + ## + # Creates the scripts to run the applications in the gem. + #-- + # The Windows script is generated in addition to the regular one due to a + # bug or misfeature in the Windows shell's pipe. See + # https://blade.ruby-lang.org/ruby-talk/193379 + + def generate_bin_script(filename, bindir) + bin_script_path = File.join bindir, formatted_program_filename(filename) + + Gem.open_file_with_lock(bin_script_path) do + require "fileutils" + FileUtils.rm_f bin_script_path # prior install may have been --no-wrappers + + File.open(bin_script_path, "wb", 0o755) do |file| + file.write app_script_text(filename) + file.chmod(options[:prog_mode] || 0o755) + end + end + + verbose bin_script_path + + generate_windows_script filename, bindir + end + + ## + # Creates the symlinks to run the applications in the gem. Moves + # the symlink if the gem being installed has a newer version. + + def generate_bin_symlink(filename, bindir) + src = File.join gem_dir, spec.bindir, filename + dst = File.join bindir, formatted_program_filename(filename) + + if File.exist? dst + if File.symlink? dst + link = File.readlink(dst).split File::SEPARATOR + cur_version = Gem::Version.create(link[-3].sub(/^.*-/, "")) + return if spec.version < cur_version + end + File.unlink dst + end + + FileUtils.symlink src, dst, verbose: Gem.configuration.really_verbose + rescue NotImplementedError, SystemCallError + alert_warning "Unable to use symlinks, installing wrapper" + generate_bin_script filename, bindir + end + + ## + # Generates a #! line for +bin_file_name+'s wrapper copying arguments if + # necessary. + # + # If the :custom_shebang config is set, then it is used as a template + # for how to create the shebang used for to run a gem's executables. + # + # The template supports 4 expansions: + # + # $env the path to the unix env utility + # $ruby the path to the currently running ruby interpreter + # $exec the path to the gem's executable + # $name the name of the gem the executable is for + # + + def shebang(bin_file_name) + path = File.join gem_dir, spec.bindir, bin_file_name + first_line = File.open(path, "rb", &:gets) || "" + + if first_line.start_with?("#!") + # Preserve extra words on shebang line, like "-w". Thanks RPA. + shebang = first_line.sub(/\A\#!.*?ruby\S*((\s+\S+)+)/, "#!#{Gem.ruby}") + opts = $1 + shebang.strip! # Avoid nasty ^M issues. + end + + if which = Gem.configuration[:custom_shebang] + # replace bin_file_name with "ruby" to avoid endless loops + which = which.gsub(/ #{bin_file_name}$/," #{ruby_install_name}") + + which = which.gsub(/\$(\w+)/) do + case $1 + when "env" + @env_path ||= ENV_PATHS.find {|env_path| File.executable? env_path } + when "ruby" + "#{Gem.ruby}#{opts}" + when "exec" + bin_file_name + when "name" + spec.name + end + end + + "#!#{which}" + elsif @env_shebang + # Create a plain shebang line. + @env_path ||= ENV_PATHS.find {|env_path| File.executable? env_path } + "#!#{@env_path} #{ruby_install_name}" + else + "#{bash_prolog_script}#!#{Gem.ruby}#{opts}" + end + end + + ## + # Ensures the Gem::Specification written out for this gem is loadable upon + # installation. + + def ensure_loadable_spec + ruby = spec.to_ruby_for_cache + + begin + eval ruby + rescue StandardError, SyntaxError => e + raise Gem::InstallError, + "The specification for #{spec.full_name} is corrupt (#{e.class})" + end + end + + def ensure_dependencies_met # :nodoc: + deps = spec.runtime_dependencies + deps |= spec.development_dependencies if @development + + deps.each do |dep_gem| + ensure_dependency spec, dep_gem + end + end + + def process_options # :nodoc: + @options = { + bin_dir: nil, + env_shebang: false, + force: false, + only_install_dir: false, + post_install_message: true, + }.merge options + + @env_shebang = options[:env_shebang] + @force = options[:force] + @install_dir = options[:install_dir] + @user_install = options[:user_install] + @ignore_dependencies = options[:ignore_dependencies] + @format_executable = options[:format_executable] + @wrappers = options[:wrappers] + @only_install_dir = options[:only_install_dir] + + @bin_dir = options[:bin_dir] + @development = options[:development] + @build_root = options[:build_root] + + @build_args = options[:build_args] + @build_jobs = options[:build_jobs] + + @gem_home = @install_dir || user_install_dir || Gem.dir + + # If the user has asked for the gem to be installed in a directory that is + # the system gem directory, then use the system bin directory, else create + # (or use) a new bin dir under the gem_home. + @bin_dir ||= Gem.bindir(@gem_home) + + @plugins_dir = Gem.plugindir(@gem_home) + + unless @build_root.nil? + @bin_dir = File.join(@build_root, @bin_dir.gsub(/^[a-zA-Z]:/, "")) + @gem_home = File.join(@build_root, @gem_home.gsub(/^[a-zA-Z]:/, "")) + @plugins_dir = File.join(@build_root, @plugins_dir.gsub(/^[a-zA-Z]:/, "")) + alert_warning "You build with buildroot.\n Build root: #{@build_root}\n Bin dir: #{@bin_dir}\n Gem home: #{@gem_home}\n Plugins dir: #{@plugins_dir}" + end + end + + def check_that_user_bin_dir_is_in_path(executables) # :nodoc: + user_bin_dir = @bin_dir || Gem.bindir(gem_home) + user_bin_dir = user_bin_dir.tr(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR + + path = ENV["PATH"] + path = path.tr(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR + + if Gem.win_platform? + path = path.downcase + user_bin_dir = user_bin_dir.downcase + end + + path = path.split(File::PATH_SEPARATOR) + + unless path.include? user_bin_dir + unless !Gem.win_platform? && (path.include? user_bin_dir.sub(ENV["HOME"], "~")) + alert_warning "You don't have #{user_bin_dir} in your PATH,\n\t gem executables (#{executables.join(", ")}) will not run." + end + end + end + + def verify_gem_home # :nodoc: + FileUtils.mkdir_p gem_home, mode: options[:dir_mode] && 0o755 + end + + def verify_spec + unless Gem::Specification::VALID_NAME_PATTERN.match?(spec.name) + raise Gem::InstallError, "#{spec} has an invalid name" + end + + if spec.raw_require_paths.any? {|path| path =~ /\R/ } + raise Gem::InstallError, "#{spec} has an invalid require_paths" + end + + if spec.extensions.any? {|ext| ext =~ /\R/ } + raise Gem::InstallError, "#{spec} has an invalid extensions" + end + + if /\R/.match?(spec.platform.to_s) + raise Gem::InstallError, "#{spec.platform} is an invalid platform" + end + + unless /\A\d+\z/.match?(spec.specification_version.to_s) + raise Gem::InstallError, "#{spec} has an invalid specification_version" + end + + if spec.dependencies.any? {|dep| dep.type != :runtime && dep.type != :development } + raise Gem::InstallError, "#{spec} has an invalid dependencies" + end + + if spec.dependencies.any? {|dep| dep.name =~ /(?:\R|[<>])/ } + raise Gem::InstallError, "#{spec} has an invalid dependencies" + end + end + + ## + # Return the text for an application file. + + def app_script_text(bin_file_name) + # NOTE: that the `load` lines cannot be indented, as old RG versions match + # against the beginning of the line + <<~TEXT + #{shebang bin_file_name} + # + # This file was generated by RubyGems. + # + # The application '#{spec.name}' is installed as part of a gem, and + # this file is here to facilitate running it. + # + + require 'rubygems' + #{gemdeps_load(spec.name)} + version = "#{Gem::Requirement.default_prerelease}" + + str = ARGV.first + if str + str = str.b[/\\A_(.*)_\\z/, 1] + if str and Gem::Version.correct?(str) + #{explicit_version_requirement(spec.name)} + ARGV.shift + end + end + + if Gem.respond_to?(:activate_and_load_bin_path) + Gem.activate_and_load_bin_path('#{spec.name}', '#{bin_file_name}', version) + else + load Gem.activate_bin_path('#{spec.name}', '#{bin_file_name}', version) + end + TEXT + end + + def gemdeps_load(name) + return "" if name == "bundler" + + <<~TEXT + + Gem.use_gemdeps + TEXT + end + + def explicit_version_requirement(name) + code = "version = str" + return code unless name == "bundler" + + code += <<~TEXT + + ENV['BUNDLER_VERSION'] = str + TEXT + end + + ## + # return the stub script text used to launch the true Ruby script + + def windows_stub_script(bindir, bin_file_name) + rb_topdir = RbConfig::TOPDIR || File.dirname(rb_config["bindir"]) + + # get ruby executable file name from RbConfig + ruby_exe = "#{rb_config["RUBY_INSTALL_NAME"]}#{rb_config["EXEEXT"]}" + ruby_exe = "ruby.exe" if ruby_exe.empty? + + if File.exist?(File.join(bindir, ruby_exe)) + # stub & ruby.exe within same folder. Portable + <<~TEXT + @ECHO OFF + @"%~dp0#{ruby_exe}" "%~dpn0" %* + TEXT + elsif bindir.downcase.start_with? rb_topdir.downcase + # stub within ruby folder, but not standard bin. Portable + require "pathname" + from = Pathname.new bindir + to = Pathname.new "#{rb_topdir}/bin" + rel = to.relative_path_from from + <<~TEXT + @ECHO OFF + @"%~dp0#{rel}/#{ruby_exe}" "%~dpn0" %* + TEXT + else + # outside ruby folder, maybe -user-install or bundler. Portable, but ruby + # is dependent on PATH + <<~TEXT + @ECHO OFF + @#{ruby_exe} "%~dpn0" %* + TEXT + end + end + ## + # Builds extensions. Valid types of extensions are extconf.rb files, + # configure scripts and rakefiles or mkrf_conf files. + + def build_extensions + if options[:build_extension] == false + warn_skipped_extensions + return + end + + builder = Gem::Ext::Builder.new spec, build_args, Gem.target_rbconfig, build_jobs + + builder.build_extensions + end + + def warn_skipped_extensions # :nodoc: + return if spec.extensions.empty? + + alert_warning "#{spec.full_name} contains native extensions that were not built.\n" \ + "To build extensions, run: gem pristine #{spec.name} --extensions" + end + + def warn_skipped_plugins # :nodoc: + return if spec.plugins.empty? + + alert_warning "#{spec.full_name} contains plugins that were not installed.\n" \ + "To install plugins, run: gem pristine #{spec.name} --only-plugins" + end + + def remove_stale_plugins # :nodoc: + return unless spec.plugins.empty? + + ensure_writable_dir @plugins_dir + remove_plugins_for(spec, @plugins_dir) + end + + ## + # Reads the file index and extracts each file into the gem directory. + # + # Ensures that files can't be installed outside the gem directory. + + def extract_files + @package.extract_files gem_dir + end + + ## + # Extracts only the bin/ files from the gem into the gem directory. + # This is used by default gems to allow a gem-aware stub to function + # without the full gem installed. + + def extract_bin + @package.extract_files gem_dir, "#{spec.bindir}/*" + end + + ## + # Prefix and suffix the program filename the same as ruby. + + def formatted_program_filename(filename) + if @format_executable + self.class.exec_format % File.basename(filename) + else + filename + end + end + + ## + # + # Return the target directory where the gem is to be installed. This + # directory is not guaranteed to be populated. + # + + def dir + gem_dir.to_s + end + + ## + # Filename of the gem being installed. + + def gem + @package.gem.path + end + + ## + # Performs various checks before installing the gem such as the install + # repository is writable and its directories exist, required Ruby and + # rubygems versions are met and that dependencies are installed. + # + # Version and dependency checks are skipped if this install is forced. + # + # The dependent check will be skipped if the install is ignoring dependencies. + + def pre_install_checks + verify_gem_home + + # The name and require_paths must be verified first, since it could contain + # ruby code that would be eval'ed in #ensure_loadable_spec + verify_spec + + ensure_loadable_spec + + Gem.ensure_gem_subdirectories gem_home + + return true if @force + + ensure_dependencies_met unless @ignore_dependencies + + true + end + + ## + # Writes the file containing the arguments for building this gem's + # extensions. + + def write_build_info_file + return if build_args.empty? + + build_info_dir = File.join gem_home, "build_info" + + dir_mode = options[:dir_mode] + FileUtils.mkdir_p build_info_dir, mode: dir_mode && 0o755 + + build_info_file = File.join build_info_dir, "#{spec.full_name}.info" + + File.open build_info_file, "w" do |io| + build_args.each do |arg| + io.puts arg + end + end + + File.chmod(dir_mode, build_info_dir) if dir_mode + end + + ## + # Writes the .gem file to the cache directory + + def write_cache_file + cache_file = File.join gem_home, "cache", spec.file_name + @package.copy_to cache_file + end + + def ensure_writable_dir(dir) # :nodoc: + require "fileutils" + FileUtils.mkdir_p dir, mode: options[:dir_mode] && 0o755 + + raise Gem::FilePermissionError.new(dir) unless File.writable? dir + end + + private + + def user_install_dir + # never install to user home in --build-root mode + return unless @build_root.nil? + + # Please note that @user_install might have three states: + # * `true`: `--user-install` + # * `false`: `--no-user-install` and + # * `nil`: option was not specified + if @user_install || (@user_install.nil? && Gem.default_user_install) + Gem.user_dir + end + end + + def build_args + @build_args ||= begin + require_relative "command" + Gem::Command.build_args + end + end + + def build_jobs + @build_jobs ||= begin + require "etc" + Etc.nprocessors + 1 + rescue LoadError + 1 + end + end + + def rb_config + Gem.target_rbconfig + end + + def ruby_install_name + rb_config["ruby_install_name"] + end + + def load_relative_enabled? + rb_config["LIBRUBY_RELATIVE"] == "yes" + end + + def bash_prolog_script + if load_relative_enabled? + <<~EOS + #!/bin/sh + # -*- ruby -*- + _=_\\ + =begin + bindir="${0%/*}" + ruby="$bindir/#{ruby_install_name}" + if [ ! -f "$ruby" ]; then + ruby="#{ruby_install_name}" + fi + exec "$ruby" "-x" "$0" "$@" + =end + EOS + else + "" + end + end + + def load_plugin + specs = Gem::Specification.find_all_by_name(spec.name) + # If old version already exists, this plugin isn't loaded + # immediately. It's for avoiding a case that multiple versions + # are loaded at the same time. + return unless specs.size == 1 + + plugin_files = spec.plugins.filter_map do |plugin| + path = File.join(@plugins_dir, "#{spec.name}_plugin#{File.extname(plugin)}") + path if File.exist?(path) + end + Gem.load_plugin_files(plugin_files) unless plugin_files.empty? + end +end diff --git a/lib/rubygems/installer_uninstaller_utils.rb b/lib/rubygems/installer_uninstaller_utils.rb new file mode 100644 index 0000000000..c5c2a52bab --- /dev/null +++ b/lib/rubygems/installer_uninstaller_utils.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +## +# Helper methods for both Gem::Installer and Gem::Uninstaller + +module Gem::InstallerUninstallerUtils + def regenerate_plugins_for(spec, plugins_dir) + plugins = spec.plugins + return if plugins.empty? + + require "pathname" + + spec.plugins.each do |plugin| + plugin_script_path = File.join plugins_dir, "#{spec.name}_plugin#{File.extname(plugin)}" + + File.open plugin_script_path, "wb" do |file| + file.puts "require_relative '#{Pathname.new(plugin).relative_path_from(Pathname.new(plugins_dir))}'" + end + + verbose plugin_script_path + end + end + + def remove_plugins_for(spec, plugins_dir) + FileUtils.rm_f Gem::Util.glob_files_in_dir("#{spec.name}#{Gem.plugin_suffix_pattern}", plugins_dir) + end +end diff --git a/lib/rubygems/local_remote_options.rb b/lib/rubygems/local_remote_options.rb new file mode 100644 index 0000000000..3b88c43149 --- /dev/null +++ b/lib/rubygems/local_remote_options.rb @@ -0,0 +1,146 @@ +# 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 "vendor/uri/lib/uri" +require_relative "../rubygems" + +## +# Mixin methods for local and remote Gem::Command options. + +module Gem::LocalRemoteOptions + ## + # Allows Gem::OptionParser to handle HTTP URIs. + + def accept_uri_http + Gem::OptionParser.accept Gem::URI::HTTP do |value| + begin + uri = Gem::URI.parse value + rescue Gem::URI::InvalidURIError + raise Gem::OptionParser::InvalidArgument, value + end + + valid_uri_schemes = ["http", "https", "file", "s3"] + unless valid_uri_schemes.include?(uri.scheme) + msg = "Invalid uri scheme for #{value}\nPreface URLs with one of #{valid_uri_schemes.map {|s| "#{s}://" }}" + raise ArgumentError, msg + end + + value + end + end + + ## + # Add local/remote options to the command line parser. + + def add_local_remote_options + add_option(:"Local/Remote", "-l", "--local", + "Restrict operations to the LOCAL domain") do |_value, options| + options[:domain] = :local + end + + add_option(:"Local/Remote", "-r", "--remote", + "Restrict operations to the REMOTE domain") do |_value, options| + options[:domain] = :remote + end + + add_option(:"Local/Remote", "-b", "--both", + "Allow LOCAL and REMOTE operations") do |_value, options| + options[:domain] = :both + end + + add_bulk_threshold_option + add_clear_sources_option + add_source_option + add_proxy_option + add_update_sources_option + end + + ## + # Add the --bulk-threshold option + + def add_bulk_threshold_option + add_option(:"Local/Remote", "-B", "--bulk-threshold COUNT", + "Threshold for switching to bulk", + "synchronization (default #{Gem.configuration.bulk_threshold})") do |value, _options| + Gem.configuration.bulk_threshold = value.to_i + end + end + + ## + # Add the --clear-sources option + + def add_clear_sources_option + add_option(:"Local/Remote", "--clear-sources", + "Clear the gem sources") do |_value, options| + Gem.sources = nil + options[:sources_cleared] = true + end + end + + ## + # Add the --http-proxy option + + def add_proxy_option + accept_uri_http + + add_option(:"Local/Remote", "-p", "--[no-]http-proxy [URL]", Gem::URI::HTTP, + "Use HTTP proxy for remote operations") do |value, options| + options[:http_proxy] = value == false ? :no_proxy : value + Gem.configuration[:http_proxy] = options[:http_proxy] + end + end + + ## + # Add the --source option + + def add_source_option + accept_uri_http + + add_option(:"Local/Remote", "-s", "--source URL", Gem::URI::HTTP, + "Append URL to list of remote gem sources") do |source, options| + source << "/" unless source.end_with?("/") + + if options.delete :sources_cleared + Gem.sources = [source] + else + Gem.sources << source unless Gem.sources.include?(source) + end + end + end + + ## + # Add the --update-sources option + + def add_update_sources_option + add_option(:Deprecated, "-u", "--[no-]update-sources", + "Update local source cache") do |value, _options| + Gem.configuration.update_sources = value + end + end + + ## + # Is fetching of local and remote information enabled? + + def both? + options[:domain] == :both + end + + ## + # Is local fetching enabled? + + def local? + [:local, :both].include?(options[:domain]) + end + + ## + # Is remote fetching enabled? + + def remote? + [:remote, :both].include?(options[:domain]) + end +end diff --git a/lib/rubygems/name_tuple.rb b/lib/rubygems/name_tuple.rb new file mode 100644 index 0000000000..cbdf4d7ac5 --- /dev/null +++ b/lib/rubygems/name_tuple.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +## +# +# Represents a gem of name +name+ at +version+ of +platform+. These +# wrap the data returned from the indexes. + +class Gem::NameTuple + def initialize(name, version, platform = Gem::Platform::RUBY) + @name = name + @version = version + + platform &&= platform.to_s + platform = Gem::Platform::RUBY if !platform || platform.empty? + @platform = platform + end + + attr_reader :name, :version, :platform + + ## + # Turn an array of [name, version, platform] into an array of + # NameTuple objects. + + def self.from_list(list) + list.map {|t| new(*t) } + end + + ## + # Turn an array of NameTuple objects back into an array of + # [name, version, platform] tuples. + + def self.to_basic(list) + list.map(&:to_a) + end + + ## + # A null NameTuple, ie name=nil, version=0 + + def self.null + new nil, Gem::Version.new(0), nil + end + + ## + # Returns the full name (name-version) of this Gem. Platform information is + # included if it is not the default Ruby platform. This mimics the behavior + # of Gem::Specification#full_name. + + def full_name + case @platform + when nil, "", Gem::Platform::RUBY + "#{@name}-#{@version}" + else + "#{@name}-#{@version}-#{@platform}" + end + end + + ## + # Indicate if this NameTuple matches the current platform. + + def match_platform? + Gem::Platform.match_gem? @platform, @name + end + + ## + # Indicate if this NameTuple is for a prerelease version. + def prerelease? + @version.prerelease? + end + + ## + # Return the name that the gemspec file would be + + def spec_name + "#{full_name}.gemspec" + end + + ## + # Convert back to the [name, version, platform] tuple + + def to_a + [@name, @version, @platform] + end + + alias_method :deconstruct, :to_a + + def deconstruct_keys(keys) + { name: @name, version: @version, platform: @platform } + end + + def inspect # :nodoc: + "#<Gem::NameTuple #{@name}, #{@version}, #{@platform}>" + end + + alias_method :to_s, :inspect # :nodoc: + + def <=>(other) + [@name, @version, Gem::Platform.sort_priority(@platform)] <=> + [other.name, other.version, Gem::Platform.sort_priority(other.platform)] + end + + include Comparable + + ## + # Compare with +other+. Supports another NameTuple or an Array + # in the [name, version, platform] format. + + def ==(other) + case other + when self.class + @name == other.name && + @version == other.version && + @platform == other.platform + when Array + to_a == other + else + false + end + end + + alias_method :eql?, :== + + def hash + to_a.hash + end +end diff --git a/lib/rubygems/openssl.rb b/lib/rubygems/openssl.rb new file mode 100644 index 0000000000..c44f619c4c --- /dev/null +++ b/lib/rubygems/openssl.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +autoload :OpenSSL, "openssl" + +module Gem + HAVE_OPENSSL = defined? OpenSSL::SSL # :nodoc: +end diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb new file mode 100644 index 0000000000..7e41b18f66 --- /dev/null +++ b/lib/rubygems/package.rb @@ -0,0 +1,769 @@ +# frozen_string_literal: true + +# rubocop:disable Style/AsciiComments + +# Copyright (C) 2004 Mauricio Julio Fernández Pradier +# See LICENSE.txt for additional licensing information. + +# rubocop:enable Style/AsciiComments + +require_relative "win_platform" +require_relative "security" +require_relative "user_interaction" + +## +# Example using a Gem::Package +# +# Builds a .gem file given a Gem::Specification. A .gem file is a tarball +# which contains a data.tar.gz, metadata.gz, checksums.yaml.gz and possibly +# signatures. +# +# require 'rubygems' +# require 'rubygems/package' +# +# spec = Gem::Specification.new do |s| +# s.summary = "Ruby based make-like utility." +# s.name = 'rake' +# s.version = PKG_VERSION +# s.requirements << 'none' +# s.files = PKG_FILES +# s.description = <<-EOF +# Rake is a Make-like program implemented in Ruby. Tasks +# and dependencies are specified in standard Ruby syntax. +# EOF +# end +# +# Gem::Package.build spec +# +# Reads a .gem file. +# +# require 'rubygems' +# require 'rubygems/package' +# +# the_gem = Gem::Package.new(path_to_dot_gem) +# the_gem.contents # get the files in the gem +# the_gem.extract_files destination_directory # extract the gem into a directory +# the_gem.spec # get the spec out of the gem +# the_gem.verify # check the gem is OK (contains valid gem specification, contains a not corrupt contents archive) +# +# #files are the files in the .gem tar file, not the Ruby files in the gem +# #extract_files and #contents automatically call #verify + +class Gem::Package + include Gem::UserInteraction + + class Error < Gem::Exception; end + + class FormatError < Error + attr_reader :path + + def initialize(message, source = nil) + if source + @path = source.is_a?(String) ? source : source.path + + message += " in #{path}" if path + end + + super message + end + end + + class PathError < Error + def initialize(destination, destination_dir) + super format("installing into parent path %s of %s is not allowed", destination, destination_dir) + end + end + + class SymlinkError < Error + def initialize(name, destination, destination_dir) + super format("installing symlink '%s' pointing to parent path %s of %s is not allowed", name, destination, destination_dir) + end + end + + class NonSeekableIO < Error; end + + class TooLongFileName < Error; end + + ## + # Raised when a tar file is corrupt + + class TarInvalidError < Error; end + + attr_accessor :build_time # :nodoc: + + ## + # Checksums for the contents of the package + + attr_reader :checksums + + ## + # The files in this package. This is not the contents of the gem, just the + # files in the top-level container. + + attr_reader :files + + ## + # Reference to the gem being packaged. + + attr_reader :gem + + ## + # The security policy used for verifying the contents of this package. + + attr_accessor :security_policy + + ## + # Sets the Gem::Specification to use to build this package. + + attr_writer :spec + + ## + # Permission for directories + attr_accessor :dir_mode + + ## + # Permission for program files + attr_accessor :prog_mode + + ## + # Permission for other files + attr_accessor :data_mode + + def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil) + gem_file = file_name || spec.file_name + + package = new gem_file + package.spec = spec + package.build skip_validation, strict_validation + + gem_file + end + + ## + # Creates a new Gem::Package for the file at +gem+. +gem+ can also be + # provided as an IO object. + # + # If +gem+ is an existing file in the old format a Gem::Package::Old will be + # returned. + + def self.new(gem, security_policy = nil) + gem = if gem.is_a?(Gem::Package::Source) + gem + elsif gem.respond_to? :read + Gem::Package::IOSource.new gem + else + Gem::Package::FileSource.new gem + end + + return super unless self == Gem::Package + return super unless gem.present? + + return super unless gem.start + return super unless gem.start.include? "MD5SUM =" + + Gem::Package::Old.new gem + end + + ## + # Extracts the Gem::Specification and raw metadata from the .gem file at + # +path+. + #-- + + def self.raw_spec(path, security_policy = nil) + format = new(path, security_policy) + spec = format.spec + + metadata = nil + + File.open path, Gem.binary_mode do |io| + tar = Gem::Package::TarReader.new io + tar.each_entry do |entry| + case entry.full_name + when "metadata" then + metadata = entry.read + when "metadata.gz" then + metadata = Gem::Util.gunzip entry.read + end + end + end + + [spec, metadata] + end + + ## + # Creates a new package that will read or write to the file +gem+. + + def initialize(gem, security_policy) # :notnew: + require "zlib" + + @gem = gem + + @build_time = Gem.source_date_epoch + @checksums = {} + @contents = nil + @digests = Hash.new {|h, algorithm| h[algorithm] = {} } + @files = nil + @security_policy = security_policy + @signatures = {} + @signer = nil + @spec = nil + end + + ## + # Copies this package to +path+ (if possible) + + def copy_to(path) + FileUtils.cp @gem.path, path unless File.exist? path + end + + ## + # Adds a checksum for each entry in the gem to checksums.yaml.gz. + + def add_checksums(tar) + Gem.load_yaml + + checksums_by_algorithm = Hash.new {|h, algorithm| h[algorithm] = {} } + + @checksums.each do |name, digests| + digests.each do |algorithm, digest| + checksums_by_algorithm[algorithm][name] = digest.hexdigest + end + end + + tar.add_file_signed "checksums.yaml.gz", 0o444, @signer do |io| + gzip_to io do |gz_io| + if Gem.use_psych? + Psych.dump checksums_by_algorithm, gz_io + else + gz_io.write Gem::YAMLSerializer.dump(checksums_by_algorithm) + end + end + end + end + + ## + # Adds the files listed in the packages's Gem::Specification to data.tar.gz + # and adds this file to the +tar+. + + def add_contents(tar) # :nodoc: + digests = tar.add_file_signed "data.tar.gz", 0o444, @signer do |io| + gzip_to io do |gz_io| + Gem::Package::TarWriter.new gz_io do |data_tar| + add_files data_tar + end + end + end + + @checksums["data.tar.gz"] = digests + end + + ## + # Adds files included the package's Gem::Specification to the +tar+ file + + def add_files(tar) # :nodoc: + @spec.files.each do |file| + stat = File.lstat file + + if stat.symlink? + tar.add_symlink file, File.readlink(file), stat.mode + end + + next unless stat.file? + + tar.add_file_simple file, stat.mode, stat.size do |dst_io| + File.open file, "rb" do |src_io| + copy_stream(src_io, dst_io, stat.size) + end + end + end + end + + ## + # Adds the package's Gem::Specification to the +tar+ file + + def add_metadata(tar) # :nodoc: + digests = tar.add_file_signed "metadata.gz", 0o444, @signer do |io| + gzip_to io do |gz_io| + gz_io.write @spec.to_yaml + end + end + + @checksums["metadata.gz"] = digests + end + + ## + # Builds this package based on the specification set by #spec= + + def build(skip_validation = false, strict_validation = false) + raise ArgumentError, "skip_validation = true and strict_validation = true are incompatible" if skip_validation && strict_validation + + Gem.load_yaml + + @spec.validate true, strict_validation unless skip_validation + + setup_signer( + signer_options: { + expiration_length_days: Gem.configuration.cert_expiration_length_days, + } + ) + + @gem.with_write_io do |gem_io| + Gem::Package::TarWriter.new gem_io do |gem| + add_metadata gem + add_contents gem + add_checksums gem + end + end + + say <<-EOM + Successfully built RubyGem + Name: #{@spec.name} + Version: #{@spec.version} + File: #{File.basename @gem.path} +EOM + ensure + @signer = nil + end + + ## + # A list of file names contained in this gem + + def contents + return @contents if @contents + + verify unless @spec + + @contents = [] + + @gem.with_read_io do |io| + gem_tar = Gem::Package::TarReader.new io + + gem_tar.each do |entry| + next unless entry.full_name == "data.tar.gz" + + open_tar_gz entry do |pkg_tar| + pkg_tar.each do |contents_entry| + @contents << contents_entry.full_name + end + end + + return @contents + end + end + rescue Zlib::GzipFile::Error, EOFError, Gem::Package::TarInvalidError => e + raise Gem::Package::FormatError.new e.message, @gem + end + + ## + # Creates a digest of the TarEntry +entry+ from the digest algorithm set by + # the security policy. + + def digest(entry) # :nodoc: + algorithms = if @checksums + @checksums.to_h {|algorithm, _| [algorithm, Gem::Security.create_digest(algorithm)] } + elsif Gem::Security::DIGEST_NAME + { Gem::Security::DIGEST_NAME => Gem::Security.create_digest(Gem::Security::DIGEST_NAME) } + end + + return @digests if algorithms.nil? || algorithms.empty? + + buf = String.new(capacity: 16_384, encoding: Encoding::BINARY) + until entry.eof? + entry.readpartial(16_384, buf) + algorithms.each_value {|digester| digester << buf } + end + entry.rewind + + algorithms.each do |algorithm, digester| + @digests[algorithm][entry.full_name] = digester + end + + @digests + end + + ## + # Extracts the files in this package into +destination_dir+ + # + # If +pattern+ is specified, only entries matching that glob will be + # extracted. + + def extract_files(destination_dir, pattern = "*") + verify unless @spec + + FileUtils.mkdir_p destination_dir, mode: dir_mode && 0o755 + + @gem.with_read_io do |io| + reader = Gem::Package::TarReader.new io + + reader.each do |entry| + next unless entry.full_name == "data.tar.gz" + + extract_tar_gz entry, destination_dir, pattern + + break # ignore further entries + end + end + rescue Zlib::GzipFile::Error, EOFError, Gem::Package::TarInvalidError => e + raise Gem::Package::FormatError.new e.message, @gem + end + + ## + # Extracts all the files in the gzipped tar archive +io+ into + # +destination_dir+. + # + # If an entry in the archive contains a relative path above + # +destination_dir+ or an absolute path is encountered an exception is + # raised. + # + # If +pattern+ is specified, only entries matching that glob will be + # extracted. + + def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc: + destination_dir = File.realpath(destination_dir) + + directories = [] + symlinks = [] + + open_tar_gz io do |tar| + tar.each do |entry| + full_name = entry.full_name + next unless File.fnmatch pattern, full_name, File::FNM_DOTMATCH + + destination = install_location full_name, destination_dir + + if entry.symlink? + link_target = entry.header.linkname + real_destination = link_target.start_with?("/") ? link_target : File.expand_path(link_target, File.dirname(destination)) + + raise Gem::Package::SymlinkError.new(full_name, real_destination, destination_dir) unless + normalize_path(real_destination).start_with? normalize_path(destination_dir + "/") + + symlinks << [full_name, link_target, destination, real_destination] + end + + mkdir = + if entry.directory? + destination + else + File.dirname destination + end + + unless directories.include?(mkdir) + FileUtils.mkdir_p mkdir, mode: dir_mode ? 0o755 : (entry.header.mode if entry.directory?) + directories << mkdir + end + + real_mkdir = File.realpath(mkdir) + unless real_mkdir == destination_dir || normalize_path(real_mkdir).start_with?(normalize_path(destination_dir + "/")) + raise Gem::Package::PathError.new(real_mkdir, destination_dir) + end + + if entry.file? + File.open(destination, "wb") do |out| + copy_stream(tar.io, out, entry.size) + # Flush needs to happen before chmod because there could be data + # in the IO buffer that needs to be written, and that could be + # written after the chmod (on close) which would mess up the perms + out.flush + out.chmod file_mode(entry.header.mode) & ~File.umask + end + end + + verbose destination + end + end + + symlinks.each do |name, target, destination, real_destination| + if File.exist?(real_destination) + create_symlink(target, destination) + else + alert_warning "#{@spec.full_name} ships with a dangling symlink named #{name} pointing to missing #{target} file. Ignoring" + end + end + + if dir_mode + File.chmod(dir_mode, *directories) + end + end + + def file_mode(mode) # :nodoc: + ((mode & 0o111).zero? ? data_mode : prog_mode) || + # If we're not using one of the default modes, then we're going to fall + # back to the mode from the tarball. In this case we need to mask it down + # to fit into 2^16 bits (the maximum value for a mode in CRuby since it + # gets put into an unsigned short). + (mode & ((1 << 16) - 1)) + end + + ## + # Gzips content written to +gz_io+ to +io+. + #-- + # Also sets the gzip modification time to the package build time to ease + # testing. + + def gzip_to(io) # :yields: gz_io + gz_io = Zlib::GzipWriter.new io, Zlib::BEST_COMPRESSION + gz_io.mtime = @build_time + + yield gz_io + ensure + gz_io.close + end + + ## + # Returns the full path for installing +filename+. + # + # If +filename+ is not inside +destination_dir+ an exception is raised. + + def install_location(filename, destination_dir) # :nodoc: + raise Gem::Package::PathError.new(filename, destination_dir) if + filename.start_with? "/" + + destination_dir = File.realpath(destination_dir) + destination = File.expand_path(filename, destination_dir) + + raise Gem::Package::PathError.new(destination, destination_dir) unless + normalize_path(destination).start_with? normalize_path(destination_dir + "/") + + destination + end + + if Gem.win_platform? + def normalize_path(pathname) # :nodoc: + pathname.downcase + end + else + def normalize_path(pathname) # :nodoc: + pathname + end + end + + ## + # Loads a Gem::Specification from the TarEntry +entry+ + + def load_spec_from_metadata(entry) # :nodoc: + limit = 10 * 1024 * 1024 + case entry.full_name + when "metadata" then + @spec = Gem::Specification.from_yaml limit_read(entry, "metadata", limit) + when "metadata.gz" then + Zlib::GzipReader.wrap(entry, external_encoding: Encoding::UTF_8) do |gzio| + @spec = Gem::Specification.from_yaml limit_read(gzio, "metadata.gz", limit) + end + end + end + + ## + # Opens +io+ as a gzipped tar archive + + def open_tar_gz(io) # :nodoc: + Zlib::GzipReader.wrap io do |gzio| + tar = Gem::Package::TarReader.new gzio + + yield tar + ensure + # Consume remaining gzip data to prevent the + # "attempt to close unfinished zstream; reset forced" warning + # when the GzipReader is closed with unconsumed compressed data. + begin + IO.copy_stream(gzio, IO::NULL) + rescue Zlib::GzipFile::Error, IOError + nil + end + end + end + + ## + # Reads and loads checksums.yaml.gz from the tar file +gem+ + + def read_checksums(gem) + Gem.load_yaml + + @checksums = gem.seek "checksums.yaml.gz" do |entry| + Zlib::GzipReader.wrap entry do |gz_io| + Gem::SafeYAML.safe_load limit_read(gz_io, "checksums.yaml.gz", 10 * 1024 * 1024) + end + end + end + + ## + # Prepares the gem for signing and checksum generation. If a signing + # certificate and key are not present only checksum generation is set up. + + def setup_signer(signer_options: {}) + passphrase = ENV["GEM_PRIVATE_KEY_PASSPHRASE"] + if @spec.signing_key + @signer = + Gem::Security::Signer.new( + @spec.signing_key, + @spec.cert_chain, + passphrase, + signer_options + ) + + @spec.signing_key = nil + @spec.cert_chain = @signer.cert_chain.map(&:to_s) + else + @signer = Gem::Security::Signer.new nil, nil, passphrase + @spec.cert_chain = @signer.cert_chain.map(&:to_pem) if + @signer.cert_chain + end + end + + ## + # The spec for this gem. + # + # If this is a package for a built gem the spec is loaded from the + # gem and returned. If this is a package for a gem being built the provided + # spec is returned. + + def spec + verify unless @spec + + @spec + end + + ## + # Verifies that this gem: + # + # * Contains a valid gem specification + # * Contains a contents archive + # * The contents archive is not corrupt + # + # After verification the gem specification from the gem is available from + # #spec + + def verify + @files = [] + @spec = nil + + @gem.with_read_io do |io| + Gem::Package::TarReader.new io do |reader| + read_checksums reader + + verify_files reader + end + end + + verify_checksums @digests, @checksums + + @security_policy&.verify_signatures @spec, @digests, @signatures + + true + rescue Gem::Security::Exception + @spec = nil + @files = [] + raise + rescue Errno::ENOENT => e + raise Gem::Package::FormatError.new e.message + rescue Zlib::GzipFile::Error, EOFError, Gem::Package::TarInvalidError => e + raise Gem::Package::FormatError.new e.message, @gem + end + + private + + ## + # Verifies the +checksums+ against the +digests+. This check is not + # cryptographically secure. Missing checksums are ignored. + + def verify_checksums(digests, checksums) # :nodoc: + return unless checksums + + checksums.sort.each do |algorithm, gem_digests| + gem_digests.sort.each do |file_name, gem_hexdigest| + computed_digest = digests[algorithm][file_name] + + unless computed_digest.hexdigest == gem_hexdigest + raise Gem::Package::FormatError.new \ + "#{algorithm} checksum mismatch for #{file_name}", @gem + end + end + end + end + + ## + # Verifies +entry+ in a .gem file. + + def verify_entry(entry) + file_name = entry.full_name + @files << file_name + + case file_name + when /\.sig$/ then + @signatures[$`] = limit_read(entry, file_name, 1024 * 1024) if @security_policy + return + else + digest entry + end + + load_spec_from_metadata entry + rescue StandardError + warn "Exception while verifying #{@gem.path}" + raise + end + + ## + # Verifies the files of the +gem+ + + def verify_files(gem) + gem.each do |entry| + verify_entry entry + end + + unless @spec + raise Gem::Package::FormatError.new "package metadata is missing", @gem + end + + unless @files.include? "data.tar.gz" + raise Gem::Package::FormatError.new \ + "package content (data.tar.gz) is missing", @gem + end + + if (duplicates = @files.group_by {|f| f }.select {|_k,v| v.size > 1 }.map(&:first)) && duplicates.any? + raise Gem::Security::Exception, "duplicate files in the package: (#{duplicates.map(&:inspect).join(", ")})" + end + end + + if RUBY_ENGINE == "truffleruby" + def copy_stream(src, dst, size) # :nodoc: + dst.write src.read(size) + end + else + def copy_stream(src, dst, size) # :nodoc: + IO.copy_stream(src, dst, size) + end + end + + def limit_read(io, name, limit) + bytes = io.read(limit + 1) + raise Gem::Package::FormatError, "#{name} is too big (over #{limit} bytes)" if bytes.size > limit + bytes + end + + if Gem.win_platform? + # Create a symlink and fallback to copy the file or directory on Windows, + # where symlink creation needs special privileges in form of the Developer Mode. + # JRuby on Windows raises TypeError from the wincode path-conversion helper + # when it cannot create the symlink, so fall back to copy in that case too. + def create_symlink(old_name, new_name) + File.symlink(old_name, new_name) + rescue Errno::EACCES, TypeError + from = File.expand_path(old_name, File.dirname(new_name)) + FileUtils.cp_r(from, new_name) + end + else + def create_symlink(old_name, new_name) + File.symlink(old_name, new_name) + end + end +end + +require_relative "package/digest_io" +require_relative "package/source" +require_relative "package/file_source" +require_relative "package/io_source" +require_relative "package/old" +require_relative "package/tar_header" +require_relative "package/tar_reader" +require_relative "package/tar_reader/entry" +require_relative "package/tar_writer" diff --git a/lib/rubygems/package/digest_io.rb b/lib/rubygems/package/digest_io.rb new file mode 100644 index 0000000000..f04ab97462 --- /dev/null +++ b/lib/rubygems/package/digest_io.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +## +# IO wrapper that creates digests of contents written to the IO it wraps. + +class Gem::Package::DigestIO + ## + # Collected digests for wrapped writes. + # + # { + # 'SHA1' => #<OpenSSL::Digest: [...]>, + # 'SHA512' => #<OpenSSL::Digest: [...]>, + # } + + attr_reader :digests + + ## + # Wraps +io+ and updates digest for each of the digest algorithms in + # the +digests+ Hash. Returns the digests hash. Example: + # + # io = StringIO.new + # digests = { + # 'SHA1' => OpenSSL::Digest.new('SHA1'), + # 'SHA512' => OpenSSL::Digest.new('SHA512'), + # } + # + # Gem::Package::DigestIO.wrap io, digests do |digest_io| + # digest_io.write "hello" + # end + # + # digests['SHA1'].hexdigest #=> "aaf4c61d[...]" + # digests['SHA512'].hexdigest #=> "9b71d224[...]" + + def self.wrap(io, digests) + digest_io = new io, digests + + yield digest_io + + digests + end + + ## + # Creates a new DigestIO instance. Using ::wrap is recommended, see the + # ::wrap documentation for documentation of +io+ and +digests+. + + def initialize(io, digests) + @io = io + @digests = digests + end + + ## + # Writes +data+ to the underlying IO and updates the digests + + def write(data) + result = @io.write data + + @digests.each do |_, digest| + digest << data + end + + result + end +end diff --git a/lib/rubygems/package/file_source.rb b/lib/rubygems/package/file_source.rb new file mode 100644 index 0000000000..d9717e0f2a --- /dev/null +++ b/lib/rubygems/package/file_source.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +## +# The primary source of gems is a file on disk, including all usages +# internal to rubygems. +# +# This is a private class, do not depend on it directly. Instead, pass a path +# object to `Gem::Package.new`. + +class Gem::Package::FileSource < Gem::Package::Source # :nodoc: all + attr_reader :path + + def initialize(path) + @path = path + end + + def start + @start ||= File.read path, 20 + end + + def present? + File.exist? path + end + + def with_write_io(&block) + File.open path, "wb", &block + end + + def with_read_io(&block) + File.open path, "rb", &block + end +end diff --git a/lib/rubygems/package/io_source.rb b/lib/rubygems/package/io_source.rb new file mode 100644 index 0000000000..227835dfce --- /dev/null +++ b/lib/rubygems/package/io_source.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +## +# Supports reading and writing gems from/to a generic IO object. This is +# useful for other applications built on top of rubygems, such as +# rubygems.org. +# +# This is a private class, do not depend on it directly. Instead, pass an IO +# object to `Gem::Package.new`. + +class Gem::Package::IOSource < Gem::Package::Source # :nodoc: all + attr_reader :io + + def initialize(io) + @io = io + end + + def start + @start ||= begin + if io.pos > 0 + raise Gem::Package::Error, "Cannot read start unless IO is at start" + end + + value = io.read 20 + io.rewind + value + end + end + + def present? + true + end + + def with_read_io + yield io + ensure + io.rewind + end + + def with_write_io + yield io + ensure + io.rewind + end + + def path + end +end diff --git a/lib/rubygems/package/old.rb b/lib/rubygems/package/old.rb new file mode 100644 index 0000000000..1a13ac3e29 --- /dev/null +++ b/lib/rubygems/package/old.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +## +# The format class knows the guts of the ancient .gem file format and provides +# the capability to read such ancient gems. +# +# Please pretend this doesn't exist. + +class Gem::Package::Old < Gem::Package + undef_method :spec= + + ## + # Creates a new old-format package reader for +gem+. Old-format packages + # cannot be written. + + def initialize(gem, security_policy) + require "fileutils" + require "zlib" + Gem.load_yaml + + @contents = nil + @gem = gem + @security_policy = security_policy + @spec = nil + end + + ## + # A list of file names contained in this gem + + def contents + verify + + return @contents if @contents + + @gem.with_read_io do |io| + read_until_dashes io # spec + header = file_list io + + @contents = header.map {|file| file["path"] } + end + end + + ## + # Extracts the files in this package into +destination_dir+ + + def extract_files(destination_dir) + verify + + errstr = "Error reading files from gem" + + @gem.with_read_io do |io| + read_until_dashes io # spec + header = file_list io + raise Gem::Exception, errstr unless header + + header.each do |entry| + full_name = entry["path"] + + destination = install_location full_name, destination_dir + + file_data = String.new + + read_until_dashes io do |line| + file_data << line + end + + file_data = file_data.strip.unpack1("m") + file_data = Zlib::Inflate.inflate file_data + + raise Gem::Package::FormatError, "#{full_name} in #{@gem} is corrupt" if + file_data.length != entry["size"].to_i + + FileUtils.rm_rf destination + + FileUtils.mkdir_p File.dirname(destination), mode: dir_mode && 0o755 + + File.open destination, "wb", file_mode(entry["mode"]) do |out| + out.write file_data + end + + verbose destination + end + end + rescue Zlib::DataError + raise Gem::Exception, errstr + end + + ## + # Reads the file list section from the old-format gem +io+ + + def file_list(io) # :nodoc: + header = String.new + + read_until_dashes io do |line| + header << line + end + + Gem::SafeYAML.safe_load header + end + + ## + # Reads lines until a "---" separator is found + + def read_until_dashes(io) # :nodoc: + while (line = io.gets) && line.chomp.strip != "---" do + yield line if block_given? + end + end + + ## + # Skips the Ruby self-install header in +io+. + + def skip_ruby(io) # :nodoc: + loop do + line = io.gets + + return if line.chomp == "__END__" + break unless line + end + + raise Gem::Exception, "Failed to find end of Ruby script while reading gem" + end + + ## + # The specification for this gem + + def spec + verify + + return @spec if @spec + + yaml = String.new + + @gem.with_read_io do |io| + skip_ruby io + read_until_dashes io do |line| + yaml << line + end + end + + begin + @spec = Gem::Specification.from_yaml yaml + rescue Psych::SyntaxError + raise Gem::Exception, "Failed to parse gem specification out of gem file" + end + rescue ArgumentError + raise Gem::Exception, "Failed to parse gem specification out of gem file" + end + + ## + # Raises an exception if a security policy that verifies data is active. + # Old format gems cannot be verified as signed. + + def verify + return true unless @security_policy + + raise Gem::Security::Exception, + "old format gems do not contain signatures and cannot be verified" if + @security_policy.verify_data + + true + end +end diff --git a/lib/rubygems/package/source.rb b/lib/rubygems/package/source.rb new file mode 100644 index 0000000000..8c44f8c305 --- /dev/null +++ b/lib/rubygems/package/source.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Gem::Package::Source # :nodoc: +end diff --git a/lib/rubygems/package/tar_header.rb b/lib/rubygems/package/tar_header.rb new file mode 100644 index 0000000000..dd20d65080 --- /dev/null +++ b/lib/rubygems/package/tar_header.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +# rubocop:disable Style/AsciiComments + +# Copyright (C) 2004 Mauricio Julio Fernández Pradier +# See LICENSE.txt for additional licensing information. + +# rubocop:enable Style/AsciiComments + +## +#-- +# struct tarfile_entry_posix { +# char name[100]; # ASCII + (Z unless filled) +# char mode[8]; # 0 padded, octal, null +# char uid[8]; # ditto +# char gid[8]; # ditto +# char size[12]; # 0 padded, octal, null +# char mtime[12]; # 0 padded, octal, null +# char checksum[8]; # 0 padded, octal, null, space +# char typeflag[1]; # file: "0" dir: "5" +# char linkname[100]; # ASCII + (Z unless filled) +# char magic[6]; # "ustar\0" +# char version[2]; # "00" +# char uname[32]; # ASCIIZ +# char gname[32]; # ASCIIZ +# char devmajor[8]; # 0 padded, octal, null +# char devminor[8]; # o padded, octal, null +# char prefix[155]; # ASCII + (Z unless filled) +# }; +#++ +# A header for a tar file + +class Gem::Package::TarHeader + ## + # Fields in the tar header + + FIELDS = [ + :checksum, + :devmajor, + :devminor, + :gid, + :gname, + :linkname, + :magic, + :mode, + :mtime, + :name, + :prefix, + :size, + :typeflag, + :uid, + :uname, + :version, + ].freeze + + ## + # Pack format for a tar header + + PACK_FORMAT = ("a100" + # name + "a8" + # mode + "a8" + # uid + "a8" + # gid + "a12" + # size + "a12" + # mtime + "a7a" + # chksum + "a" + # typeflag + "a100" + # linkname + "a6" + # magic + "a2" + # version + "a32" + # uname + "a32" + # gname + "a8" + # devmajor + "a8" + # devminor + "a155").freeze # prefix + + ## + # Unpack format for a tar header + + UNPACK_FORMAT = ("A100" + # name + "A8" + # mode + "A8" + # uid + "A8" + # gid + "A12" + # size + "A12" + # mtime + "A8" + # checksum + "A" + # typeflag + "A100" + # linkname + "A6" + # magic + "A2" + # version + "A32" + # uname + "A32" + # gname + "A8" + # devmajor + "A8" + # devminor + "A155").freeze # prefix + + attr_reader(*FIELDS) + + EMPTY_HEADER = ("\0" * 512).b.freeze # :nodoc: + + ## + # Creates a tar header from IO +stream+ + + def self.from(stream) + header = stream.read 512 + return EMPTY if header == EMPTY_HEADER + + fields = header.unpack UNPACK_FORMAT + + new name: fields.shift, + mode: strict_oct(fields.shift), + uid: oct_or_256based(fields.shift), + gid: oct_or_256based(fields.shift), + size: strict_oct(fields.shift), + mtime: strict_oct(fields.shift), + checksum: strict_oct(fields.shift), + typeflag: fields.shift, + linkname: fields.shift, + magic: fields.shift, + version: strict_oct(fields.shift), + uname: fields.shift, + gname: fields.shift, + devmajor: strict_oct(fields.shift), + devminor: strict_oct(fields.shift), + prefix: fields.shift, + + empty: false + end + + def self.strict_oct(str) + str.strip! + return str.oct if /\A[0-7]*\z/.match?(str) + + raise ArgumentError, "#{str.inspect} is not an octal string" + end + + def self.oct_or_256based(str) + # \x80 flags a positive 256-based number + # \ff flags a negative 256-based number + # In case we have a match, parse it as a signed binary value + # in big-endian order, except that the high-order bit is ignored. + + return str.unpack1("@4N") if /\A[\x80\xff]/n.match?(str) + strict_oct(str) + end + + ## + # Creates a new TarHeader using +vals+ + + def initialize(vals) + unless vals[:name] && vals[:size] && vals[:prefix] && vals[:mode] + raise ArgumentError, ":name, :size, :prefix and :mode required" + end + + @checksum = vals[:checksum] || "" + @devmajor = vals[:devmajor] || 0 + @devminor = vals[:devminor] || 0 + @gid = vals[:gid] || 0 + @gname = vals[:gname] || "wheel" + @linkname = vals[:linkname] + @magic = vals[:magic] || "ustar" + @mode = vals[:mode] + @mtime = vals[:mtime] || 0 + @name = vals[:name] + @prefix = vals[:prefix] + @size = vals[:size] + @typeflag = vals[:typeflag] + @typeflag = "0" if @typeflag.nil? || @typeflag.empty? + @uid = vals[:uid] || 0 + @uname = vals[:uname] || "wheel" + @version = vals[:version] || "00" + + @empty = vals[:empty] + end + + EMPTY = new({ # :nodoc: + checksum: 0, + gname: "", + linkname: "", + magic: "", + mode: 0, + name: "", + prefix: "", + size: 0, + uname: "", + version: 0, + + empty: true, + }).freeze + private_constant :EMPTY + + ## + # Is the tar entry empty? + + def empty? + @empty + end + + def ==(other) # :nodoc: + self.class === other && + @checksum == other.checksum && + @devmajor == other.devmajor && + @devminor == other.devminor && + @gid == other.gid && + @gname == other.gname && + @linkname == other.linkname && + @magic == other.magic && + @mode == other.mode && + @mtime == other.mtime && + @name == other.name && + @prefix == other.prefix && + @size == other.size && + @typeflag == other.typeflag && + @uid == other.uid && + @uname == other.uname && + @version == other.version + end + + def to_s # :nodoc: + update_checksum + header + end + + ## + # Updates the TarHeader's checksum + + def update_checksum + header = header " " * 8 + @checksum = oct calculate_checksum(header), 6 + end + + ## + # Header's full name, including prefix + + def full_name + if prefix != "" + File.join prefix, name + else + name + end + end + + private + + def calculate_checksum(header) + header.sum(0) + end + + def header(checksum = @checksum) + header = [ + name, + oct(mode, 7), + oct(uid, 7), + oct(gid, 7), + oct(size, 11), + oct(mtime, 11), + checksum, + " ", + typeflag, + linkname, + magic, + oct(version, 2), + uname, + gname, + oct(devmajor, 7), + oct(devminor, 7), + prefix, + ] + + header = header.pack PACK_FORMAT + + header.ljust 512, "\0" + end + + def oct(num, len) + format("%0#{len}o", num) + end +end diff --git a/lib/rubygems/package/tar_reader.rb b/lib/rubygems/package/tar_reader.rb new file mode 100644 index 0000000000..b66a8a62bc --- /dev/null +++ b/lib/rubygems/package/tar_reader.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# rubocop:disable Style/AsciiComments + +# Copyright (C) 2004 Mauricio Julio Fernández Pradier +# See LICENSE.txt for additional licensing information. + +# rubocop:enable Style/AsciiComments + +## +# TarReader reads tar files and allows iteration over their items + +class Gem::Package::TarReader + include Enumerable + + ## + # Creates a new TarReader on +io+ and yields it to the block, if given. + + def self.new(io) + reader = super + + return reader unless block_given? + + begin + yield reader + ensure + reader.close + end + + nil + end + + attr_reader :io # :nodoc: + + ## + # Creates a new tar file reader on +io+ which needs to respond to #pos, + # #eof?, #read, #getc and #pos= + + def initialize(io) + @io = io + @init_pos = io.pos + end + + ## + # Close the tar file + + def close + end + + ## + # Iterates over files in the tarball yielding each entry + + def each + return enum_for __method__ unless block_given? + + until @io.eof? do + begin + header = Gem::Package::TarHeader.from @io + rescue ArgumentError => e + # Specialize only exceptions from Gem::Package::TarHeader.strict_oct + raise e unless e.message.match?(/ is not an octal string$/) + raise Gem::Package::TarInvalidError, e.message + end + + return if header.empty? + entry = Gem::Package::TarReader::Entry.new header, @io + yield entry + entry.close + end + end + + alias_method :each_entry, :each + + ## + # NOTE: Do not call #rewind during #each + + def rewind + if @init_pos == 0 + @io.rewind + else + @io.pos = @init_pos + end + end + + ## + # Seeks through the tar file until it finds the +entry+ with +name+ and + # yields it. Rewinds the tar file to the beginning when the block + # terminates. + + def seek(name) # :yields: entry + found = find do |entry| + entry.full_name == name + end + + return unless found + + yield found + ensure + rewind + end +end + +require_relative "tar_reader/entry" diff --git a/lib/rubygems/package/tar_reader/entry.rb b/lib/rubygems/package/tar_reader/entry.rb new file mode 100644 index 0000000000..f837e86fd6 --- /dev/null +++ b/lib/rubygems/package/tar_reader/entry.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +# rubocop:disable Style/AsciiComments + +# Copyright (C) 2004 Mauricio Julio Fernández Pradier +# See LICENSE.txt for additional licensing information. + +# rubocop:enable Style/AsciiComments + +## +# Class for reading entries out of a tar file + +class Gem::Package::TarReader::Entry + ## + # Creates a new tar entry for +header+ that will be read from +io+ + # If a block is given, the entry is yielded and then closed. + + def self.open(header, io, &block) + entry = new header, io + return entry unless block_given? + begin + yield entry + ensure + entry.close + end + end + + ## + # Header for this tar entry + + attr_reader :header + + ## + # Creates a new tar entry for +header+ that will be read from +io+ + + def initialize(header, io) + @closed = false + @header = header + @io = io + @orig_pos = @io.pos + @end_pos = @orig_pos + @header.size + @read = 0 + end + + def check_closed # :nodoc: + raise IOError, "closed #{self.class}" if closed? + end + + ## + # Number of bytes read out of the tar entry + + def bytes_read + @read + end + + ## + # Closes the tar entry + + def close + return if closed? + # Seek to the end of the entry if it wasn't fully read + seek(0, IO::SEEK_END) + # discard trailing zeros + skip = (512 - (@header.size % 512)) % 512 + @io.read(skip) + @closed = true + nil + end + + ## + # Is the tar entry closed? + + def closed? + @closed + end + + ## + # Are we at the end of the tar entry? + + def eof? + check_closed + + @read >= @header.size + end + + ## + # Full name of the tar entry + + def full_name + @header.full_name.force_encoding(Encoding::UTF_8) + rescue ArgumentError => e + raise unless e.message == "string contains null byte" + raise Gem::Package::TarInvalidError, + "tar is corrupt, name contains null byte" + end + + ## + # Read one byte from the tar entry + + def getc + return nil if eof? + + ret = @io.getc + @read += 1 if ret + + ret + end + + ## + # Is this tar entry a directory? + + def directory? + @header.typeflag == "5" + end + + ## + # Is this tar entry a file? + + def file? + @header.typeflag == "0" + end + + ## + # Is this tar entry a symlink? + + def symlink? + @header.typeflag == "2" + end + + ## + # The position in the tar entry + + def pos + check_closed + + bytes_read + end + + ## + # Seek to the position in the tar entry + + def pos=(new_pos) + seek(new_pos, IO::SEEK_SET) + end + + def size + @header.size + end + + alias_method :length, :size + + ## + # Reads +maxlen+ bytes from the tar file entry, or the rest of the entry if nil + + def read(maxlen = nil) + if eof? + return maxlen.to_i.zero? ? "" : nil + end + + max_read = [maxlen, @header.size - @read].compact.min + + ret = @io.read max_read + if ret.nil? + return maxlen ? nil : "" # IO.read returns nil on EOF with len argument + end + @read += ret.size + + ret + end + + def readpartial(maxlen, outbuf = "".b) + if eof? && maxlen > 0 + raise EOFError, "end of file reached" + end + + max_read = [maxlen, @header.size - @read].min + + @io.readpartial(max_read, outbuf) + @read += outbuf.size + + outbuf + end + + ## + # Seeks to +offset+ bytes into the tar file entry + # +whence+ can be IO::SEEK_SET, IO::SEEK_CUR, or IO::SEEK_END + + def seek(offset, whence = IO::SEEK_SET) + check_closed + + new_pos = + case whence + when IO::SEEK_SET then @orig_pos + offset + when IO::SEEK_CUR then @io.pos + offset + when IO::SEEK_END then @end_pos + offset + else + raise ArgumentError, "invalid whence" + end + + if new_pos < @orig_pos + new_pos = @orig_pos + elsif new_pos > @end_pos + new_pos = @end_pos + end + + pending = new_pos - @io.pos + + return 0 if pending == 0 + + if @io.respond_to?(:seek) + begin + # avoid reading if the @io supports seeking + @io.seek new_pos, IO::SEEK_SET + pending = 0 + rescue Errno::EINVAL + end + end + + # if seeking isn't supported or failed + # negative seek requires that we rewind and read + if pending < 0 + @io.rewind + pending = new_pos + end + + while pending > 0 do + size_read = @io.read([pending, 4096].min)&.size + raise(EOFError, "end of file reached") if size_read.nil? + pending -= size_read + end + + @read = @io.pos - @orig_pos + + 0 + end + + ## + # Rewinds to the beginning of the tar file entry + + def rewind + check_closed + seek(0, IO::SEEK_SET) + end +end diff --git a/lib/rubygems/package/tar_writer.rb b/lib/rubygems/package/tar_writer.rb new file mode 100644 index 0000000000..39fed9e2af --- /dev/null +++ b/lib/rubygems/package/tar_writer.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +# rubocop:disable Style/AsciiComments + +# Copyright (C) 2004 Mauricio Julio Fernández Pradier +# See LICENSE.txt for additional licensing information. + +# rubocop:enable Style/AsciiComments + +## +# Allows writing of tar files + +class Gem::Package::TarWriter + class FileOverflow < StandardError; end + + ## + # IO wrapper that allows writing a limited amount of data + + class BoundedStream + ## + # Maximum number of bytes that can be written + + attr_reader :limit + + ## + # Number of bytes written + + attr_reader :written + + ## + # Wraps +io+ and allows up to +limit+ bytes to be written + + def initialize(io, limit) + @io = io + @limit = limit + @written = 0 + end + + ## + # Writes +data+ onto the IO, raising a FileOverflow exception if the + # number of bytes will be more than #limit + + def write(data) + if data.bytesize + @written > @limit + raise FileOverflow, "You tried to feed more data than fits in the file." + end + @io.write data + @written += data.bytesize + data.bytesize + end + end + + ## + # IO wrapper that provides only #write + + class RestrictedStream + ## + # Creates a new RestrictedStream wrapping +io+ + + def initialize(io) + @io = io + end + + ## + # Writes +data+ onto the IO + + def write(data) + @io.write data + end + end + + ## + # Creates a new TarWriter, yielding it if a block is given + + def self.new(io) + writer = super + + return writer unless block_given? + + begin + yield writer + ensure + writer.close + end + + nil + end + + ## + # Creates a new TarWriter that will write to +io+ + + def initialize(io) + @io = io + @closed = false + end + + ## + # Adds file +name+ with permissions +mode+ and mtime +mtime+ (sets + # Gem.source_date_epoch if not specified), and yields an IO for + # writing the file to + + def add_file(name, mode, mtime = nil) # :yields: io + check_closed + + name, prefix = split_name name + + init_pos = @io.pos + @io.write Gem::Package::TarHeader::EMPTY_HEADER # placeholder for the header + + yield RestrictedStream.new(@io) if block_given? + + size = @io.pos - init_pos - 512 + + remainder = (512 - (size % 512)) % 512 + @io.write "\0" * remainder + + final_pos = @io.pos + @io.pos = init_pos + + header = Gem::Package::TarHeader.new name: name, mode: mode, + size: size, prefix: prefix, + mtime: mtime || Gem.source_date_epoch + + @io.write header + @io.pos = final_pos + + self + end + + ## + # Adds +name+ with permissions +mode+ to the tar, yielding +io+ for writing + # the file. The +digest_algorithm+ is written to a read-only +name+.sum + # file following the given file contents containing the digest name and + # hexdigest separated by a tab. + # + # The created digest object is returned. + + def add_file_digest(name, mode, digest_algorithms) # :yields: io + digests = digest_algorithms.map do |digest_algorithm| + digest = digest_algorithm.new + digest_name = + if digest.respond_to? :name + digest.name + else + digest_algorithm.class.name[/::([^:]+)\z/, 1] + end + + [digest_name, digest] + end + + digests = Hash[*digests.flatten] + + add_file name, mode do |io| + Gem::Package::DigestIO.wrap io, digests do |digest_io| + yield digest_io + end + end + + digests + end + + ## + # Adds +name+ with permissions +mode+ to the tar, yielding +io+ for writing + # the file. The +signer+ is used to add a digest file using its + # digest_algorithm per add_file_digest and a cryptographic signature in + # +name+.sig. If the signer has no key only the checksum file is added. + # + # Returns the digest. + + def add_file_signed(name, mode, signer) + digest_algorithms = [ + signer.digest_algorithm, + Gem::Security.create_digest("SHA512"), + ].compact.uniq + + digests = add_file_digest name, mode, digest_algorithms do |io| + yield io + end + + signature_digest = digests.values.compact.find do |digest| + digest_name = + if digest.respond_to? :name + digest.name + else + digest.class.name[/::([^:]+)\z/, 1] + end + + digest_name == signer.digest_name + end + + raise "no #{signer.digest_name} in #{digests.values.compact}" unless signature_digest + + if signer.key + signature = signer.sign signature_digest.digest + + add_file_simple "#{name}.sig", 0o444, signature.length do |io| + io.write signature + end + end + + digests + end + + ## + # Add file +name+ with permissions +mode+ +size+ bytes long. Yields an IO + # to write the file to. + + def add_file_simple(name, mode, size) # :yields: io + check_closed + + name, prefix = split_name name + + header = Gem::Package::TarHeader.new(name: name, mode: mode, + size: size, prefix: prefix, + mtime: Gem.source_date_epoch).to_s + + @io.write header + os = BoundedStream.new @io, size + + yield os if block_given? + + min_padding = size - os.written + @io.write("\0" * min_padding) + + remainder = (512 - (size % 512)) % 512 + @io.write("\0" * remainder) + + self + end + + ## + # Adds symlink +name+ with permissions +mode+, linking to +target+. + + def add_symlink(name, target, mode) + check_closed + + name, prefix = split_name name + + header = Gem::Package::TarHeader.new(name: name, mode: mode, + size: 0, typeflag: "2", + linkname: target, + prefix: prefix, + mtime: Gem.source_date_epoch).to_s + + @io.write header + + self + end + + ## + # Raises IOError if the TarWriter is closed + + def check_closed + raise IOError, "closed #{self.class}" if closed? + end + + ## + # Closes the TarWriter + + def close + check_closed + + @io.write "\0" * 1024 + flush + + @closed = true + end + + ## + # Is the TarWriter closed? + + def closed? + @closed + end + + ## + # Flushes the TarWriter's IO + + def flush + check_closed + + @io.flush if @io.respond_to? :flush + end + + ## + # Creates a new directory in the tar file +name+ with +mode+ + + def mkdir(name, mode) + check_closed + + name, prefix = split_name(name) + + header = Gem::Package::TarHeader.new name: name, mode: mode, + typeflag: "5", size: 0, + prefix: prefix, + mtime: Gem.source_date_epoch + + @io.write header + + self + end + + ## + # Splits +name+ into a name and prefix that can fit in the TarHeader + + def split_name(name) # :nodoc: + if name.bytesize > 256 + raise Gem::Package::TooLongFileName.new("File \"#{name}\" has a too long path (should be 256 or less)") + end + + prefix = "" + if name.bytesize > 100 + parts = name.split("/", -1) # parts are never empty here + name = parts.pop # initially empty for names with a trailing slash ("foo/.../bar/") + prefix = parts.join("/") # if empty, then it's impossible to split (parts is empty too) + while !parts.empty? && (prefix.bytesize > 155 || name.empty?) + name = parts.pop + "/" + name + prefix = parts.join("/") + end + + if name.bytesize > 100 || prefix.empty? + raise Gem::Package::TooLongFileName.new("File \"#{prefix}/#{name}\" has a too long name (should be 100 or less)") + end + + if prefix.bytesize > 155 + raise Gem::Package::TooLongFileName.new("File \"#{prefix}/#{name}\" has a too long base path (should be 155 or less)") + end + end + + [name, prefix] + end +end diff --git a/lib/rubygems/package_task.rb b/lib/rubygems/package_task.rb new file mode 100644 index 0000000000..d26411684d --- /dev/null +++ b/lib/rubygems/package_task.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# Copyright (c) 2003, 2004 Jim Weirich, 2009 Eric Hodel +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +require_relative "../rubygems" +require_relative "package" +require "rake/packagetask" + +## +# Create a package based upon a Gem::Specification. Gem packages, as well as +# zip files and tar/gzipped packages can be produced by this task. +# +# In addition to the Rake targets generated by Rake::PackageTask, a +# Gem::PackageTask will also generate the following tasks: +# +# [<b>"<em>package_dir</em>/<em>name</em>-<em>version</em>.gem"</b>] +# Create a RubyGems package with the given name and version. +# +# Example using a Gem::Specification: +# +# require 'rubygems' +# require 'rubygems/package_task' +# +# spec = Gem::Specification.new do |s| +# s.summary = "Ruby based make-like utility." +# s.name = 'rake' +# s.version = PKG_VERSION +# s.requirements << 'none' +# s.files = PKG_FILES +# s.description = <<-EOF +# Rake is a Make-like program implemented in Ruby. Tasks +# and dependencies are specified in standard Ruby syntax. +# EOF +# end +# +# Gem::PackageTask.new(spec) do |pkg| +# pkg.need_zip = true +# pkg.need_tar = true +# end + +class Gem::PackageTask < Rake::PackageTask + ## + # Ruby Gem::Specification containing the metadata for this package. The + # name, version and package_files are automatically determined from the + # gemspec and don't need to be explicitly provided. + + attr_accessor :gem_spec + + ## + # Create a Gem Package task library. Automatically define the gem if a + # block is given. If no block is supplied, then #define needs to be called + # to define the task. + + def initialize(gem_spec) + init gem_spec + yield self if block_given? + define if block_given? + end + + ## + # Initialization tasks without the "yield self" or define operations. + + def init(gem) + super gem.full_name, :noversion + @gem_spec = gem + @package_files += gem_spec.files if gem_spec.files + @fileutils_output = $stdout + end + + ## + # Create the Rake tasks and actions specified by this Gem::PackageTask. + # (+define+ is automatically called if a block is given to +new+). + + def define + super + + gem_file = File.basename gem_spec.cache_file + gem_path = File.join package_dir, gem_file + gem_dir = File.join package_dir, gem_spec.full_name + + task package: [:gem] + + directory package_dir + directory gem_dir + + desc "Build the gem file #{gem_file}" + task gem: [gem_path] + + trace = Rake.application.options.trace + Gem.configuration.verbose = trace + + file gem_path => [package_dir, gem_dir] + @gem_spec.files do + chdir(gem_dir) do + when_writing "Creating #{gem_spec.file_name}" do + Gem::Package.build gem_spec + + verbose trace do + mv gem_file, ".." + end + end + end + end + end +end diff --git a/lib/rubygems/path_support.rb b/lib/rubygems/path_support.rb new file mode 100644 index 0000000000..13091e29ba --- /dev/null +++ b/lib/rubygems/path_support.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +## +# +# Gem::PathSupport facilitates the GEM_HOME and GEM_PATH environment settings +# to the rest of RubyGems. +# +class Gem::PathSupport + ## + # The default system path for managing Gems. + attr_reader :home + + ## + # Array of paths to search for Gems. + attr_reader :path + + ## + # Directory with spec cache + attr_reader :spec_cache_dir # :nodoc: + + ## + # + # Constructor. Takes a single argument which is to be treated like a + # hashtable, or defaults to ENV, the system environment. + # + def initialize(env) + @home = normalize_home_dir(env["GEM_HOME"] || Gem.default_dir) + @path = split_gem_path env["GEM_PATH"], @home + + @spec_cache_dir = env["GEM_SPEC_CACHE"] || Gem.default_spec_cache_dir + end + + private + + def normalize_home_dir(home) + if File::ALT_SEPARATOR + home = home.gsub(File::ALT_SEPARATOR, File::SEPARATOR) + end + + expand(home) + end + + ## + # Split the Gem search path (as reported by Gem.path). + + def split_gem_path(gpaths, home) + # FIX: it should be [home, *path], not [*path, home] + + gem_path = [] + + if gpaths + gem_path = gpaths.split(Gem.path_separator) + # Handle the path_separator being set to a regexp, which will cause + # end_with? to error + if /#{Gem.path_separator}\z/.match?(gpaths) + gem_path += default_path + end + + if File::ALT_SEPARATOR + gem_path.map! do |this_path| + this_path.gsub File::ALT_SEPARATOR, File::SEPARATOR + end + end + + gem_path << home + else + gem_path = default_path + end + + gem_path.map {|path| expand(path) }.uniq + end + + # Return the default Gem path + def default_path + Gem.default_path + [@home] + end + + def expand(path) + if File.directory?(path) + File.realpath(path) + else + path + end + end +end diff --git a/lib/rubygems/platform.rb b/lib/rubygems/platform.rb new file mode 100644 index 0000000000..367b00e7e1 --- /dev/null +++ b/lib/rubygems/platform.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +## +# Available list of platforms for targeting Gem installations. +# +# See `gem help platform` for information on platform matching. + +class Gem::Platform + @local = nil + + attr_accessor :cpu, :os, :version + + def self.local(refresh: false) + return @local if @local && !refresh + @local = begin + arch = Gem.target_rbconfig["arch"] + arch = "#{arch}_60" if /mswin(?:32|64)$/.match?(arch) + new(arch) + end + end + + def self.match_platforms?(platform, platforms) + platform = Gem::Platform.new(platform) unless platform.is_a?(Gem::Platform) + platforms.any? do |local_platform| + platform.nil? || + local_platform == platform || + (local_platform != Gem::Platform::RUBY && platform =~ local_platform) + end + end + private_class_method :match_platforms? + + def self.match_spec?(spec) + match_gem?(spec.platform, spec.name) + end + + if RUBY_ENGINE == "truffleruby" + def self.match_gem?(platform, gem_name) + raise "Not a string: #{gem_name.inspect}" unless String === gem_name + + if REUSE_AS_BINARY_ON_TRUFFLERUBY.include?(gem_name) + match_platforms?(platform, [Gem::Platform::RUBY, Gem::Platform.local]) + else + match_platforms?(platform, Gem.platforms) + end + end + else + def self.match_gem?(platform, gem_name) + match_platforms?(platform, Gem.platforms) + end + end + + def self.sort_priority(platform) + platform == Gem::Platform::RUBY ? -1 : 1 + end + + def self.installable?(spec) + if spec.respond_to? :installable_platform? + spec.installable_platform? + else + match_spec? spec + end + end + + def self.new(arch) # :nodoc: + case arch + when Gem::Platform::CURRENT then + Gem::Platform.local + when Gem::Platform::RUBY, nil, "" then + Gem::Platform::RUBY + else + super + end + end + + def initialize(arch) + case arch + when Array then + @cpu, @os, @version = arch + when String then + cpu, os = arch.sub(/-+$/, "").split("-", 2) + + @cpu = if cpu&.match?(/i\d86/) + "x86" + else + cpu + end + + if os.nil? + @cpu = nil + os = cpu + end # legacy jruby + + @os, @version = case os + when /aix-?(\d+)?/ then ["aix", $1] + when /cygwin/ then ["cygwin", nil] + when /darwin-?(\d+)?/ then ["darwin", $1] + when "macruby" then ["macruby", nil] + when /^macruby-?(\d+(?:\.\d+)*)?/ then ["macruby", $1] + when /freebsd-?(\d+)?/ then ["freebsd", $1] + when "java", "jruby" then ["java", nil] + when /^java-?(\d+(?:\.\d+)*)?/ then ["java", $1] + when /^dalvik-?(\d+)?$/ then ["dalvik", $1] + when /^dotnet$/ then ["dotnet", nil] + when /^dotnet-?(\d+(?:\.\d+)*)?/ then ["dotnet", $1] + when /linux-?(\w+)?/ then ["linux", $1] + when /mingw32/ then ["mingw32", nil] + when /mingw-?(\w+)?/ then ["mingw", $1] + when /(mswin\d+)(?:[_-](\d+))?/ then + os = $1 + version = $2 + @cpu = "x86" if @cpu.nil? && os.end_with?("32") + [os, version] + when /netbsdelf/ then ["netbsdelf", nil] + when /openbsd-?(\d+\.\d+)?/ then ["openbsd", $1] + when /solaris-?(\d+\.\d+)?/ then ["solaris", $1] + when /wasi/ then ["wasi", nil] + # test + when /^(\w+_platform)-?(\d+)?/ then [$1, $2] + else ["unknown", nil] + end + when Gem::Platform then + @cpu = arch.cpu + @os = arch.os + @version = arch.version + else + raise ArgumentError, "invalid argument #{arch.inspect}" + end + end + + def to_a + [@cpu, @os, @version] + end + + def to_s + to_a.compact.join(@cpu.nil? ? "" : "-") + end + + ## + # Deconstructs the platform into an array for pattern matching. + # Returns [cpu, os, version]. + # + # Gem::Platform.new("x86_64-linux").deconstruct #=> ["x86_64", "linux", nil] + # + # This enables array pattern matching: + # + # case Gem::Platform.new("arm64-darwin-21") + # in ["arm64", "darwin", version] + # # version => "21" + # end + alias_method :deconstruct, :to_a + + ## + # Deconstructs the platform into a hash for pattern matching. + # Returns a hash with keys +:cpu+, +:os+, and +:version+. + # + # Gem::Platform.new("x86_64-darwin-20").deconstruct_keys(nil) + # #=> { cpu: "x86_64", os: "darwin", version: "20" } + # + # This enables hash pattern matching: + # + # case Gem::Platform.new("x86_64-linux") + # in cpu: "x86_64", os: "linux" + # # Matches Linux on x86_64 + # end + def deconstruct_keys(keys) + { cpu: @cpu, os: @os, version: @version } + end + + ## + # Is +other+ equal to this platform? Two platforms are equal if they have + # the same CPU, OS and version. + + def ==(other) + self.class === other && to_a == other.to_a + end + + alias_method :eql?, :== + + def hash # :nodoc: + to_a.hash + end + + ## + # Does +other+ match this platform? Two platforms match if they have the + # same CPU, or either has a CPU of 'universal', they have the same OS, and + # they have the same version, or either one has no version + # + # Additionally, the platform will match if the local CPU is 'arm' and the + # other CPU starts with "armv" (for generic 32-bit ARM family support). + # + # Of note, this method is not commutative. Indeed the OS 'linux' has a + # special case: the version is the libc name, yet while "no version" stands + # as a wildcard for a binary gem platform (as for other OSes), for the + # runtime platform "no version" stands for 'gnu'. To be able to distinguish + # these, the method receiver is the gem platform, while the argument is + # the runtime platform. + # + #-- + # NOTE: Until it can be removed, changes to this method must also be reflected in `bundler/lib/bundler/rubygems_ext.rb` + + def ===(other) + return nil unless Gem::Platform === other + + # universal-mingw32 matches x64-mingw-ucrt + return true if (@cpu == "universal" || other.cpu == "universal") && + @os.start_with?("mingw") && other.os.start_with?("mingw") + + # cpu + ([nil,"universal"].include?(@cpu) || [nil, "universal"].include?(other.cpu) || @cpu == other.cpu || + (@cpu == "arm" && other.cpu.start_with?("armv"))) && + + # os + @os == other.os && + + # version + ( + (@os != "linux" && (@version.nil? || other.version.nil?)) || + (@os == "linux" && (normalized_linux_version == other.normalized_linux_version || ["musl#{@version}", "musleabi#{@version}", "musleabihf#{@version}"].include?(other.version))) || + @version == other.version + ) + end + + #-- + # NOTE: Until it can be removed, changes to this method must also be reflected in `bundler/lib/bundler/rubygems_ext.rb` + + def normalized_linux_version + return nil unless @version + + without_gnu_nor_abi_modifiers = @version.sub(/\Agnu/, "").sub(/eabi(hf)?\Z/, "") + return nil if without_gnu_nor_abi_modifiers.empty? + + without_gnu_nor_abi_modifiers + end + + ## + # Does +other+ match this platform? If +other+ is a String it will be + # converted to a Gem::Platform first. See #=== for matching rules. + + def =~(other) + case other + when Gem::Platform then # nop + when String then + # This data is from http://gems.rubyforge.org/gems/yaml on 19 Aug 2007 + other = case other + when /^i686-darwin(\d)/ then ["x86", "darwin", $1] + when /^i\d86-linux/ then ["x86", "linux", nil] + when "java", "jruby" then [nil, "java", nil] + when /^dalvik(\d+)?$/ then [nil, "dalvik", $1] + when /dotnet(\-(\d+\.\d+))?/ then ["universal","dotnet", $2] + when /mswin32(\_(\d+))?/ then ["x86", "mswin32", $2] + when /mswin64(\_(\d+))?/ then ["x64", "mswin64", $2] + when "powerpc-darwin" then ["powerpc", "darwin", nil] + when /powerpc-darwin(\d)/ then ["powerpc", "darwin", $1] + when /sparc-solaris2.8/ then ["sparc", "solaris", "2.8"] + when /universal-darwin(\d)/ then ["universal", "darwin", $1] + else other + end + + other = Gem::Platform.new other + else + return nil + end + + self === other + end + + ## + # A pure-Ruby gem that may use Gem::Specification#extensions to build + # binary files. + + RUBY = "ruby" + + ## + # A platform-specific gem that is built for the packaging Ruby's platform. + # This will be replaced with Gem::Platform::local. + + CURRENT = "current" + + JAVA = Gem::Platform.new("java") # :nodoc: + MSWIN = Gem::Platform.new("mswin32") # :nodoc: + MSWIN64 = Gem::Platform.new("mswin64") # :nodoc: + MINGW = Gem::Platform.new("x86-mingw32") # :nodoc: + X64_MINGW_LEGACY = Gem::Platform.new("x64-mingw32") # :nodoc: + X64_MINGW = Gem::Platform.new("x64-mingw-ucrt") # :nodoc: + UNIVERSAL_MINGW = Gem::Platform.new("universal-mingw") # :nodoc: + WINDOWS = [MSWIN, MSWIN64, UNIVERSAL_MINGW].freeze # :nodoc: + X64_LINUX = Gem::Platform.new("x86_64-linux") # :nodoc: + X64_LINUX_MUSL = Gem::Platform.new("x86_64-linux-musl") # :nodoc: + + GENERICS = [JAVA, *WINDOWS].freeze # :nodoc: + private_constant :GENERICS + + GENERIC_CACHE = GENERICS.each_with_object({}) {|g, h| h[g] = g } # :nodoc: + private_constant :GENERIC_CACHE + + class << self + ## + # Returns the generic platform for the given platform. + + def generic(platform) + return Gem::Platform::RUBY if platform.nil? || platform == Gem::Platform::RUBY + + GENERIC_CACHE[platform] ||= begin + found = GENERICS.find do |match| + platform === match + end + found || Gem::Platform::RUBY + end + end + + ## + # Returns the platform specificity match for the given spec platform and user platform. + + def platform_specificity_match(spec_platform, user_platform) + return -1 if spec_platform == user_platform + return 1_000_000 if spec_platform.nil? || spec_platform == Gem::Platform::RUBY || user_platform == Gem::Platform::RUBY + + os_match(spec_platform, user_platform) + + cpu_match(spec_platform, user_platform) * 10 + + version_match(spec_platform, user_platform) * 100 + end + + ## + # Sorts and filters the best platform match for the given matching specs and platform. + + def sort_and_filter_best_platform_match(matching, platform) + return matching if matching.one? + + exact = matching.select {|spec| spec.platform == platform } + return exact if exact.any? + + sorted_matching = sort_best_platform_match(matching, platform) + exemplary_spec = sorted_matching.first + + sorted_matching.take_while {|spec| same_specificity?(platform, spec, exemplary_spec) && same_deps?(spec, exemplary_spec) } + end + + ## + # Sorts the best platform match for the given matching specs and platform. + + def sort_best_platform_match(matching, platform) + matching.sort_by.with_index do |spec, i| + [ + platform_specificity_match(spec.platform, platform), + i, # for stable sort + ] + end + end + + private + + def same_specificity?(platform, spec, exemplary_spec) + platform_specificity_match(spec.platform, platform) == platform_specificity_match(exemplary_spec.platform, platform) + end + + def same_deps?(spec, exemplary_spec) + spec.required_ruby_version == exemplary_spec.required_ruby_version && + spec.required_rubygems_version == exemplary_spec.required_rubygems_version && + spec.dependencies.sort == exemplary_spec.dependencies.sort + end + + def os_match(spec_platform, user_platform) + if spec_platform.os == user_platform.os + 0 + else + 1 + end + end + + def cpu_match(spec_platform, user_platform) + if spec_platform.cpu == user_platform.cpu + 0 + elsif spec_platform.cpu == "arm" && user_platform.cpu.to_s.start_with?("arm") + 0 + elsif spec_platform.cpu.nil? || spec_platform.cpu == "universal" + 1 + else + 2 + end + end + + def version_match(spec_platform, user_platform) + if spec_platform.version == user_platform.version + 0 + elsif spec_platform.version.nil? + 1 + else + 2 + end + end + end +end diff --git a/lib/rubygems/psych_tree.rb b/lib/rubygems/psych_tree.rb new file mode 100644 index 0000000000..8b4c425a33 --- /dev/null +++ b/lib/rubygems/psych_tree.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gem + if defined? ::Psych::Visitors + class NoAliasYAMLTree < Psych::Visitors::YAMLTree + def self.create + new({}) + end unless respond_to? :create + + def visit_String(str) + return super unless str == "=" # or whatever you want + + quote = Psych::Nodes::Scalar::SINGLE_QUOTED + @emitter.scalar str, nil, nil, false, true, quote + end + + def visit_Hash(o) + super(o.compact) + end + + # Noop this out so there are no anchors + def register(target, obj) + end + + # This is ported over from the YAMLTree implementation in Ruby 1.9.3 + def format_time(time) + if time.utc? + time.strftime("%Y-%m-%d %H:%M:%S.%9N Z") + else + time.strftime("%Y-%m-%d %H:%M:%S.%9N %:z") + end + end + + private :format_time + end + end +end diff --git a/lib/rubygems/query_utils.rb b/lib/rubygems/query_utils.rb new file mode 100644 index 0000000000..9849370b1a --- /dev/null +++ b/lib/rubygems/query_utils.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +require_relative "local_remote_options" +require_relative "spec_fetcher" +require_relative "version_option" +require_relative "text" + +module Gem::QueryUtils + include Gem::Text + include Gem::LocalRemoteOptions + include Gem::VersionOption + + def add_query_options + add_option("-i", "--[no-]installed", + "Check for installed gem") do |value, options| + options[:installed] = value + end + + add_option("-I", "Equivalent to --no-installed") do |_value, options| + options[:installed] = false + end + + add_version_option command, "for use with --installed" + + add_option("-d", "--[no-]details", + "Display detailed information of gem(s)") do |value, options| + options[:details] = value + end + + add_option("--[no-]versions", + "Display only gem names") do |value, options| + options[:versions] = value + options[:details] = false unless value + end + + add_option("-a", "--all", + "Display all gem versions") do |value, options| + options[:all] = value + end + + add_option("-e", "--exact", + "Name of gem(s) to query on matches the", + "provided STRING") do |value, options| + options[:exact] = value + end + + add_option("--[no-]prerelease", + "Display prerelease versions") do |value, options| + options[:prerelease] = value + end + + add_local_remote_options + end + + def defaults_str # :nodoc: + "--local --no-details --versions --no-installed" + end + + def execute + gem_names = if args.empty? + [options[:name]] + else + options[:exact] ? args.map {|arg| /\A#{Regexp.escape(arg)}\Z/ } : args.map {|arg| /#{arg}/i } + end + + terminate_interaction(check_installed_gems(gem_names)) if check_installed_gems? + + gem_names.each {|n| show_gems(n) } + end + + private + + def check_installed_gems(gem_names) + exit_code = 0 + + if args.empty? && !gem_name? + alert_error "You must specify a gem name" + exit_code = 4 + elsif gem_names.count > 1 + alert_error "You must specify only ONE gem!" + exit_code = 4 + else + installed = installed?(gem_names.first, options[:version]) + installed = !installed unless options[:installed] + + say(installed) + exit_code = 1 unless installed + end + + exit_code + end + + def check_installed_gems? + !options[:installed].nil? + end + + def gem_name? + !options[:name].nil? + end + + def prerelease + options[:prerelease] + end + + def show_prereleases? + prerelease.nil? || prerelease + end + + def args + options[:args].to_a + end + + def display_header(type) + if (ui.outs.tty? && Gem.configuration.verbose) || both? + say + say "*** #{type} GEMS ***" + say + end + end + + # Guts of original execute + def show_gems(name) + show_local_gems(name) if local? + show_remote_gems(name) if remote? + end + + def show_local_gems(name, req = Gem::Requirement.default) + display_header("LOCAL") + + specs = Gem::Specification.find_all do |s| + name_matches = name ? s.name =~ name : true + version_matches = show_prereleases? || !s.version.prerelease? + + name_matches && version_matches + end.uniq(&:full_name) + + spec_tuples = specs.map do |spec| + [spec.name_tuple, spec] + end + + output_query_results(spec_tuples) + end + + def show_remote_gems(name) + display_header("REMOTE") + + fetcher = Gem::SpecFetcher.fetcher + + spec_tuples = if name.nil? + fetcher.detect(specs_type) { true } + else + fetcher.detect(specs_type) do |name_tuple| + name === name_tuple.name && options[:version].satisfied_by?(name_tuple.version) + end + end + + output_query_results(spec_tuples) + end + + def specs_type + if options[:all] || options[:version].specific? + if options[:prerelease] + :complete + else + :released + end + elsif options[:prerelease] + :prerelease + else + :latest + end + end + + ## + # Check if gem +name+ version +version+ is installed. + + def installed?(name, req = Gem::Requirement.default) + Gem::Specification.any? {|s| s.name =~ name && req =~ s.version } + end + + def output_query_results(spec_tuples) + output = [] + versions = Hash.new {|h,name| h[name] = [] } + + spec_tuples.each do |spec_tuple, source| + versions[spec_tuple.name] << [spec_tuple, source] + end + + versions = versions.sort_by do |(n,_),_| + n.downcase + end + + output_versions output, versions + + say output.join(options[:details] ? "\n\n" : "\n") + end + + def output_versions(output, versions) + versions.each do |_gem_name, matching_tuples| + matching_tuples = matching_tuples.sort_by {|n,_| n.version }.reverse + + platforms = Hash.new {|h,version| h[version] = [] } + + matching_tuples.each do |n, _| + platforms[n.version] << n.platform if n.platform + end + + seen = {} + + matching_tuples.delete_if do |n,_| + if seen[n.version] + true + else + seen[n.version] = true + false + end + end + + output << clean_text(make_entry(matching_tuples, platforms)) + end + end + + def entry_details(entry, detail_tuple, specs, platforms) + return unless options[:details] + + name_tuple, spec = detail_tuple + + spec = spec.fetch_spec(name_tuple)if spec.respond_to?(:fetch_spec) + + entry << "\n" + + spec_platforms entry, platforms + spec_authors entry, spec + spec_homepage entry, spec + spec_license entry, spec + spec_loaded_from entry, spec, specs + spec_summary entry, spec + end + + def entry_versions(entry, name_tuples, platforms, specs) + return unless options[:versions] + + list = + if platforms.empty? || options[:details] + name_tuples.map(&:version).uniq + else + platforms.sort.reverse.map do |version, pls| + out = version.to_s + + if options[:domain] == :local + default = specs.any? do |s| + !s.is_a?(Gem::Source) && s.version == version && s.default_gem? + end + out = "default: #{out}" if default + end + + if pls != [Gem::Platform::RUBY] + platform_list = [pls.delete(Gem::Platform::RUBY), *pls.sort].compact + out = platform_list.unshift(out).join(" ") + end + + out + end + end + + entry << " (#{list.join ", "})" + end + + def make_entry(entry_tuples, platforms) + detail_tuple = entry_tuples.first + + name_tuples, specs = entry_tuples.flatten.partition do |item| + Gem::NameTuple === item + end + + entry = [name_tuples.first.name] + + entry_versions(entry, name_tuples, platforms, specs) + entry_details(entry, detail_tuple, specs, platforms) + + entry.join + end + + def spec_authors(entry, spec) + authors = "Author#{spec.authors.length > 1 ? "s" : ""}: ".dup + authors << spec.authors.join(", ") + entry << format_text(authors, 68, 4) + end + + def spec_homepage(entry, spec) + return if spec.homepage.nil? || spec.homepage.empty? + + entry << "\n" << format_text("Homepage: #{spec.homepage}", 68, 4) + end + + def spec_license(entry, spec) + return if spec.license.nil? || spec.license.empty? + + licenses = "License#{spec.licenses.length > 1 ? "s" : ""}: ".dup + licenses << spec.licenses.join(", ") + entry << "\n" << format_text(licenses, 68, 4) + end + + def spec_loaded_from(entry, spec, specs) + return unless spec.loaded_from + + if specs.length == 1 + default = spec.default_gem? ? " (default)" : nil + entry << "\n" << " Installed at#{default}: #{spec.base_dir}" + else + label = "Installed at" + specs.each do |s| + version = s.version.to_s + default = s.default_gem? ? ", default" : "" + entry << "\n" << " #{label} (#{version}#{default}): #{s.base_dir}" + label = " " * label.length + end + end + end + + def spec_platforms(entry, platforms) + non_ruby = platforms.any? do |_, pls| + pls.any? {|pl| pl != Gem::Platform::RUBY } + end + + return unless non_ruby + + if platforms.length == 1 + title = platforms.values.length == 1 ? "Platform" : "Platforms" + entry << " #{title}: #{platforms.values.sort.join(", ")}\n" + else + entry << " Platforms:\n" + + sorted_platforms = platforms.sort + + sorted_platforms.each do |version, pls| + label = " #{version}: " + data = format_text pls.sort.join(", "), 68, label.length + data[0, label.length] = label + entry << data << "\n" + end + end + end + + def spec_summary(entry, spec) + summary = truncate_text(spec.summary, "the summary for #{spec.full_name}") + entry << "\n\n" << format_text(summary, 68, 4) + end +end diff --git a/lib/rubygems/rdoc.rb b/lib/rubygems/rdoc.rb new file mode 100644 index 0000000000..3524b161b2 --- /dev/null +++ b/lib/rubygems/rdoc.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../rubygems" + +begin + require "rdoc/rubygems_hook" + module Gem + ## + # Returns whether RDoc defines its own install hooks through a RubyGems + # plugin. This and whatever is guarded by it can be removed once no + # supported Ruby ships with RDoc older than 6.9.0. + + def self.rdoc_hooks_defined_via_plugin? + Gem::Version.new(::RDoc::VERSION) >= Gem::Version.new("6.9.0") + end + + if rdoc_hooks_defined_via_plugin? + RDoc = ::RDoc::RubyGemsHook + else + RDoc = ::RDoc::RubygemsHook + + Gem.done_installing(&Gem::RDoc.method(:generation_hook)) + end + end +rescue LoadError +end diff --git a/lib/rubygems/remote_fetcher.rb b/lib/rubygems/remote_fetcher.rb new file mode 100644 index 0000000000..5b83dc6f6f --- /dev/null +++ b/lib/rubygems/remote_fetcher.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require_relative "../rubygems" +require_relative "request" +require_relative "request/connection_pools" +require_relative "s3_uri_signer" +require_relative "uri_formatter" +require_relative "uri" +require_relative "user_interaction" + +## +# RemoteFetcher handles the details of fetching gems and gem information from +# a remote source. + +class Gem::RemoteFetcher + include Gem::UserInteraction + + ## + # A FetchError exception wraps up the various possible IO and HTTP failures + # that could happen while downloading from the internet. + + class FetchError < Gem::Exception + ## + # The URI which was being accessed when the exception happened. + + attr_accessor :uri, :original_uri + + def initialize(message, uri) + uri = Gem::Uri.new(uri) + + super uri.redact_credentials_from(message) + + @original_uri = uri.to_s + @uri = uri.redacted.to_s + end + + def to_s # :nodoc: + "#{super} (#{uri})" + end + end + + ## + # A FetchError that indicates that the reason for not being + # able to fetch data was that the host could not be contacted + + class UnknownHostError < FetchError + end + deprecate_constant(:UnknownHostError) + + @fetcher = nil + + ## + # Cached RemoteFetcher instance. + + def self.fetcher + @fetcher ||= new Gem.configuration[:http_proxy] + end + + attr_accessor :headers + + ## + # Initialize a remote fetcher using the source URI and possible proxy + # information. + # + # +proxy+ + # * [String]: explicit specification of proxy; overrides any environment + # variable setting + # * nil: respect environment variables (HTTP_PROXY, HTTP_PROXY_USER, + # HTTP_PROXY_PASS) + # * <tt>:no_proxy</tt>: ignore environment variables and _don't_ use a proxy + # + # +headers+: A set of additional HTTP headers to be sent to the server when + # fetching the gem. + + def initialize(proxy = nil, dns = nil, headers = {}) + require_relative "core_ext/tcpsocket_init" if Gem.configuration.ipv4_fallback_enabled + require_relative "vendored_net_http" + require_relative "vendor/uri/lib/uri" + + Socket.do_not_reverse_lookup = true + + @proxy = proxy + @pools = {} + @pool_lock = Thread::Mutex.new + @pool_size = 1 + @cert_files = Gem::Request.get_cert_files + + @headers = headers + end + + ## + # Given a name and requirement, downloads this gem into cache and returns the + # filename. Returns nil if the gem cannot be located. + #-- + # Should probably be integrated with #download below, but that will be a + # larger, more encompassing effort. -erikh + + def download_to_cache(dependency) + found, _ = Gem::SpecFetcher.fetcher.spec_for_dependency dependency + + return if found.empty? + + spec, source = found.max_by {|(s,_)| s.version } + + download spec, source.uri + end + + ## + # Moves the gem +spec+ from +source_uri+ to the cache dir unless it is + # already there. If the source_uri is local the gem cache dir copy is + # always replaced. + + def download(spec, source_uri, install_dir = Gem.dir) + gem_file_name = File.basename spec.cache_file + + install_cache_dir = File.join install_dir, "cache" + cache_dir = + if Gem.configuration.global_gem_cache + Gem.global_gem_cache_path + elsif Dir.pwd == install_dir # see fetch_command + install_dir + elsif File.writable?(install_cache_dir) || (File.writable?(install_dir) && !File.exist?(install_cache_dir)) + install_cache_dir + else + File.join Gem.user_dir, "cache" + end + + local_gem_path = File.join cache_dir, gem_file_name + + require "fileutils" + begin + FileUtils.mkdir_p cache_dir + rescue StandardError + nil + end unless File.exist? cache_dir + + source_uri = Gem::Uri.new(source_uri) + + scheme = source_uri.scheme + + # Gem::URI.parse gets confused by MS Windows paths with forward slashes. + scheme = nil if /^[a-z]$/i.match?(scheme) + + # REFACTOR: split this up and dispatch on scheme (eg download_http) + # REFACTOR: be sure to clean up fake fetcher when you do this... cleaner + case scheme + when "http", "https", "s3" then + unless File.exist? local_gem_path + begin + verbose "Downloading gem #{gem_file_name}" + + remote_gem_path = source_uri + "gems/#{gem_file_name}" + + cache_update_path remote_gem_path, local_gem_path + rescue FetchError + raise if spec.original_platform == spec.platform + + alternate_name = "#{spec.original_name}.gem" + + verbose "Failed, downloading gem #{alternate_name}" + + remote_gem_path = source_uri + "gems/#{alternate_name}" + + cache_update_path remote_gem_path, local_gem_path + end + end + when "file" then + begin + path = source_uri.path + path = File.dirname(path) if File.extname(path) == ".gem" + + remote_gem_path = Gem::Util.correct_for_windows_path(File.join(path, "gems", gem_file_name)) + + FileUtils.cp(remote_gem_path, local_gem_path) + rescue Errno::EACCES + local_gem_path = source_uri.to_s + end + + verbose "Using local gem #{local_gem_path}" + when nil then + source_path = if Gem.win_platform? && source_uri.scheme && + !source_uri.path.include?(":") + "#{source_uri.scheme}:#{source_uri.path}" + else + source_uri.path + end + + source_path = Gem::UriFormatter.new(source_path).unescape + + begin + FileUtils.cp source_path, local_gem_path unless + File.identical?(source_path, local_gem_path) + rescue Errno::EACCES + local_gem_path = source_uri.to_s + end + + verbose "Using local gem #{local_gem_path}" + else + raise ArgumentError, "unsupported URI scheme #{source_uri.scheme}" + end + + local_gem_path + end + + ## + # File Fetcher. Dispatched by +fetch_path+. Use it instead. + + def fetch_file(uri, *_) + Gem.read_binary Gem::Util.correct_for_windows_path uri.path + end + + ## + # HTTP Fetcher. Dispatched by +fetch_path+. Use it instead. + + def fetch_http(uri, last_modified = nil, head = false, depth = 0) + fetch_type = head ? Gem::Net::HTTP::Head : Gem::Net::HTTP::Get + response = request uri, fetch_type, last_modified do |req| + headers.each {|k,v| req.add_field(k,v) } + end + + case response + when Gem::Net::HTTPOK, Gem::Net::HTTPNotModified then + response.uri = uri + head ? response : response.body + when Gem::Net::HTTPMovedPermanently, Gem::Net::HTTPFound, Gem::Net::HTTPSeeOther, + Gem::Net::HTTPTemporaryRedirect then + raise FetchError.new("too many redirects", uri) if depth > 10 + + unless location = response["Location"] + raise FetchError.new("redirecting but no redirect location was given", uri) + end + location = Gem::Uri.new location + + if https?(uri) && !https?(location) + raise FetchError.new("redirecting to non-https resource: #{location}", uri) + end + + fetch_http(location, last_modified, head, depth + 1) + else + custom_error = response["X-Error-Message"] + error_detail = custom_error || response.message + raise FetchError.new("Bad response #{error_detail} #{response.code}", uri) + end + end + + alias_method :fetch_https, :fetch_http + + ## + # Downloads +uri+ and returns it as a String. + + def fetch_path(uri, mtime = nil, head = false) + uri = Gem::Uri.new uri + + method = { + "http" => "fetch_http", + "https" => "fetch_http", + "s3" => "fetch_s3", + "file" => "fetch_file", + }.fetch(uri.scheme) { raise ArgumentError, "uri scheme is invalid: #{uri.scheme.inspect}" } + + data = send method, uri, mtime, head + + if data && !head && uri.to_s.end_with?(".gz") + begin + data = Gem::Util.gunzip data + rescue Zlib::GzipFile::Error + raise FetchError.new("server did not return a valid file", uri) + end + end + + data + rescue Gem::Timeout::Error, IOError, SocketError, SystemCallError, + *(OpenSSL::SSL::SSLError if Gem::HAVE_OPENSSL) => e + raise FetchError.new("#{e.class}: #{e}", uri) + end + + def fetch_s3(uri, mtime = nil, head = false) + begin + public_uri = s3_uri_signer(uri, head ? "HEAD" : "GET").sign + rescue Gem::S3URISigner::ConfigurationError, Gem::S3URISigner::InstanceProfileError => e + raise FetchError.new(e.message, "s3://#{uri.host}") + end + fetch_https public_uri, mtime, head + end + + # we have our own signing code here to avoid a dependency on the aws-sdk gem + def s3_uri_signer(uri, method) + Gem::S3URISigner.new(uri, method) + end + + ## + # Downloads +uri+ to +path+ if necessary. If no path is given, it just + # passes the data. + + def cache_update_path(uri, path = nil, update = true) + mtime = begin + path && File.stat(path).mtime + rescue StandardError + nil + end + + data = fetch_path(uri, mtime) + + if data.nil? # indicates the server returned 304 Not Modified + return Gem.read_binary(path) + end + + if update && path + Gem.write_binary(path, data) + end + + data + end + + ## + # Performs a Gem::Net::HTTP request of type +request_class+ on +uri+ returning + # a Gem::Net::HTTP response object. request maintains a table of persistent + # connections to reduce connect overhead. + + def request(uri, request_class, last_modified = nil) + proxy = proxy_for @proxy, uri + pool = pools_for(proxy).pool_for uri + + request = Gem::Request.new uri, request_class, last_modified, pool + + request.fetch do |req| + yield req if block_given? + end + end + + def https?(uri) + uri.scheme.casecmp("https").zero? + end + + def close_all + @pools.each_value(&:close_all) + end + + private + + def proxy_for(proxy, uri) + Gem::Request.proxy_uri(proxy || Gem::Request.get_proxy_from_env(uri.scheme)) + end + + def pools_for(proxy) + @pool_lock.synchronize do + @pools[proxy] ||= Gem::Request::ConnectionPools.new proxy, @cert_files, @pool_size + end + end +end diff --git a/lib/rubygems/request.rb b/lib/rubygems/request.rb new file mode 100644 index 0000000000..e817ee5704 --- /dev/null +++ b/lib/rubygems/request.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +require_relative "vendored_net_http" +require_relative "user_interaction" +require_relative "uri_formatter" + +class Gem::Request + extend Gem::UserInteraction + include Gem::UserInteraction + + ### + # Legacy. This is used in tests. + def self.create_with_proxy(uri, request_class, last_modified, proxy) # :nodoc: + cert_files = get_cert_files + proxy ||= get_proxy_from_env(uri.scheme) + pool = ConnectionPools.new proxy_uri(proxy), cert_files + + new(uri, request_class, last_modified, pool.pool_for(uri)) + end + + def self.proxy_uri(proxy) # :nodoc: + require_relative "vendor/uri/lib/uri" + case proxy + when :no_proxy then nil + when Gem::URI::HTTP then proxy + else Gem::URI.parse(proxy) + end + end + + def initialize(uri, request_class, last_modified, pool) + @uri = uri + @request_class = request_class + @last_modified = last_modified + @requests = Hash.new(0).compare_by_identity + @user_agent = user_agent + + @connection_pool = pool + end + + def proxy_uri + @connection_pool.proxy_uri + end + + def cert_files + @connection_pool.cert_files + end + + def self.get_cert_files + pattern = File.expand_path("./ssl_certs/*/*.pem", __dir__) + Dir.glob(pattern) + end + + def self.configure_connection_for_https(connection, cert_files) + raise Gem::Exception.new("OpenSSL is not available. Install OpenSSL and rebuild Ruby (preferred) or use non-HTTPS sources") unless Gem::HAVE_OPENSSL + + connection.use_ssl = true + connection.verify_mode = + Gem.configuration.ssl_verify_mode || OpenSSL::SSL::VERIFY_PEER + store = OpenSSL::X509::Store.new + + if Gem.configuration.ssl_client_cert + pem = File.read Gem.configuration.ssl_client_cert + connection.cert = OpenSSL::X509::Certificate.new pem + connection.key = OpenSSL::PKey::RSA.new pem + end + + store.set_default_paths + cert_files.each do |ssl_cert_file| + store.add_file ssl_cert_file + end + if Gem.configuration.ssl_ca_cert + if File.directory? Gem.configuration.ssl_ca_cert + store.add_path Gem.configuration.ssl_ca_cert + else + store.add_file Gem.configuration.ssl_ca_cert + end + end + connection.cert_store = store + + connection.verify_callback = proc do |preverify_ok, store_context| + verify_certificate store_context unless preverify_ok + + preverify_ok + end + + connection + end + + def self.verify_certificate(store_context) + depth = store_context.error_depth + error = store_context.error_string + number = store_context.error + cert = store_context.current_cert + + ui.alert_error "SSL verification error at depth #{depth}: #{error} (#{number})" + + extra_message = verify_certificate_message number, cert + + ui.alert_error extra_message if extra_message + end + + def self.verify_certificate_message(error_number, cert) + return unless cert + case error_number + when OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED then + require "time" + "Certificate #{cert.subject} expired at #{cert.not_after.iso8601}" + when OpenSSL::X509::V_ERR_CERT_NOT_YET_VALID then + require "time" + "Certificate #{cert.subject} not valid until #{cert.not_before.iso8601}" + when OpenSSL::X509::V_ERR_CERT_REJECTED then + "Certificate #{cert.subject} is rejected" + when OpenSSL::X509::V_ERR_CERT_UNTRUSTED then + "Certificate #{cert.subject} is not trusted" + when OpenSSL::X509::V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT then + "Certificate #{cert.issuer} is not trusted" + when OpenSSL::X509::V_ERR_INVALID_CA then + "Certificate #{cert.subject} is an invalid CA certificate" + when OpenSSL::X509::V_ERR_INVALID_PURPOSE then + "Certificate #{cert.subject} has an invalid purpose" + when OpenSSL::X509::V_ERR_SELF_SIGNED_CERT_IN_CHAIN then + "Root certificate is not trusted (#{cert.subject})" + when OpenSSL::X509::V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY then + "You must add #{cert.issuer} to your local trusted store" + when + OpenSSL::X509::V_ERR_UNABLE_TO_VERIFY_LEAF_SIGNATURE then + "Cannot verify certificate issued by #{cert.issuer}" + end + end + + ## + # Creates or an HTTP connection based on +uri+, or retrieves an existing + # connection, using a proxy if needed. + + def connection_for(uri) + @connection_pool.checkout + rescue Gem::HAVE_OPENSSL ? OpenSSL::SSL::SSLError : Errno::EHOSTDOWN, + Errno::EHOSTDOWN => e + raise Gem::RemoteFetcher::FetchError.new(e.message, uri) + end + + def fetch + request = @request_class.new @uri.request_uri + + unless @uri.nil? || @uri.user.nil? || @uri.user.empty? + request.basic_auth Gem::UriFormatter.new(@uri.user).unescape, + Gem::UriFormatter.new(@uri.password).unescape + end + + request.add_field "User-Agent", @user_agent + request.add_field "Connection", "keep-alive" + request.add_field "Keep-Alive", "30" + + if @last_modified + require "time" + request.add_field "If-Modified-Since", @last_modified.httpdate + end + + yield request if block_given? + + perform_request request + end + + ## + # Returns a proxy URI for the given +scheme+ if one is set in the + # environment variables. + + def self.get_proxy_from_env(scheme = "http") + downcase_scheme = scheme.downcase + upcase_scheme = scheme.upcase + env_proxy = ENV["#{downcase_scheme}_proxy"] || ENV["#{upcase_scheme}_PROXY"] + + no_env_proxy = env_proxy.nil? || env_proxy.empty? + + if no_env_proxy + return ["https", "http"].include?(downcase_scheme) ? :no_proxy : get_proxy_from_env("http") + end + + require "uri" + uri = Gem::URI(Gem::UriFormatter.new(env_proxy).normalize) + + if uri && uri.user.nil? && uri.password.nil? + user = ENV["#{downcase_scheme}_proxy_user"] || ENV["#{upcase_scheme}_PROXY_USER"] + password = ENV["#{downcase_scheme}_proxy_pass"] || ENV["#{upcase_scheme}_PROXY_PASS"] + + uri.user = Gem::UriFormatter.new(user).escape + uri.password = Gem::UriFormatter.new(password).escape + end + + uri + end + + def perform_request(request) # :nodoc: + connection = connection_for @uri + + retried = false + bad_response = false + + begin + @requests[connection] += 1 + + verbose "#{request.method} #{Gem::Uri.redact(@uri)}" + + file_name = File.basename(@uri.path) + # perform download progress reporter only for gems + if request.response_body_permitted? && file_name =~ /\.gem$/ + reporter = ui.download_reporter + response = connection.request(request) do |incomplete_response| + if Gem::Net::HTTPOK === incomplete_response + reporter.fetch(file_name, incomplete_response.content_length) + downloaded = 0 + data = String.new + + incomplete_response.read_body do |segment| + data << segment + downloaded += segment.length + reporter.update(downloaded) + end + reporter.done + if incomplete_response.respond_to? :body= + incomplete_response.body = data + else + incomplete_response.instance_variable_set(:@body, data) + end + end + end + else + response = connection.request request + end + + verbose "#{response.code} #{response.message}" + rescue Gem::Net::HTTPBadResponse + verbose "bad response" + + reset connection + + raise Gem::RemoteFetcher::FetchError.new("too many bad responses", @uri) if bad_response + + bad_response = true + retry + rescue Gem::Net::HTTPFatalError + verbose "fatal error" + + raise Gem::RemoteFetcher::FetchError.new("fatal error", @uri) + # HACK: work around EOFError bug in Gem::Net::HTTP + # NOTE Errno::ECONNABORTED raised a lot on Windows, and make impossible + # to install gems. + rescue EOFError, Gem::Timeout::Error, + Errno::ECONNABORTED, Errno::ECONNRESET, Errno::EPIPE + + requests = @requests[connection] + verbose "connection reset after #{requests} requests, retrying" + + raise Gem::RemoteFetcher::FetchError.new("too many connection resets", @uri) if retried + + reset connection + + retried = true + retry + end + + response + ensure + @connection_pool.checkin connection + end + + ## + # Resets HTTP connection +connection+. + + def reset(connection) + @requests.delete connection + + connection.finish + connection.start + end + + def user_agent + ua = "RubyGems/#{Gem::VERSION} #{Gem::Platform.local}".dup + + ruby_version = RUBY_VERSION + ruby_version += "dev" if RUBY_PATCHLEVEL == -1 + + ua << " Ruby/#{ruby_version} (#{RUBY_RELEASE_DATE}" + if RUBY_PATCHLEVEL >= 0 + ua << " patchlevel #{RUBY_PATCHLEVEL}" + else + ua << " revision #{RUBY_REVISION}" + end + ua << ")" + + ua << " #{RUBY_ENGINE}" if RUBY_ENGINE != "ruby" + + ua + end +end + +require_relative "request/http_pool" +require_relative "request/https_pool" +require_relative "request/connection_pools" diff --git a/lib/rubygems/request/connection_pools.rb b/lib/rubygems/request/connection_pools.rb new file mode 100644 index 0000000000..01e7e0629a --- /dev/null +++ b/lib/rubygems/request/connection_pools.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Gem::Request::ConnectionPools # :nodoc: + @client = Gem::Net::HTTP + + class << self + attr_accessor :client + end + + def initialize(proxy_uri, cert_files, pool_size = 1) + @proxy_uri = proxy_uri + @cert_files = cert_files + @pools = {} + @pool_mutex = Thread::Mutex.new + @pool_size = pool_size + end + + def pool_for(uri) + http_args = net_http_args(uri, @proxy_uri) + key = http_args + [https?(uri)] + @pool_mutex.synchronize do + @pools[key] ||= + if https? uri + Gem::Request::HTTPSPool.new(http_args, @cert_files, @proxy_uri, @pool_size) + else + Gem::Request::HTTPPool.new(http_args, @cert_files, @proxy_uri, @pool_size) + end + end + end + + def close_all + @pools.each_value(&:close_all) + end + + private + + ## + # Returns list of no_proxy entries (if any) from the environment + + def get_no_proxy_from_env + env_no_proxy = ENV["no_proxy"] || ENV["NO_PROXY"] + + return [] if env_no_proxy.nil? || env_no_proxy.empty? + + env_no_proxy.split(/\s*,\s*/) + end + + def https?(uri) + uri.scheme.casecmp("https").zero? + end + + def no_proxy?(host, env_no_proxy) + host = host.downcase + + env_no_proxy.any? do |pattern| + env_no_proxy_pattern = pattern.downcase.dup + + # Remove dot in front of pattern for wildcard matching + env_no_proxy_pattern[0] = "" if env_no_proxy_pattern[0] == "." + + host_tokens = host.split(".") + pattern_tokens = env_no_proxy_pattern.split(".") + + intersection = (host_tokens - pattern_tokens) | (pattern_tokens - host_tokens) + + # When we do the split into tokens we miss a dot character, so add it back if we need it + missing_dot = intersection.length > 0 ? 1 : 0 + start = intersection.join(".").size + missing_dot + + no_proxy_host = host[start..-1] + + env_no_proxy_pattern == no_proxy_host + end + end + + def net_http_args(uri, proxy_uri) + hostname = uri.hostname + net_http_args = [hostname, uri.port] + + no_proxy = get_no_proxy_from_env + + if proxy_uri && !no_proxy?(hostname, no_proxy) + proxy_hostname = proxy_uri.respond_to?(:hostname) ? proxy_uri.hostname : proxy_uri.host + net_http_args + [ + proxy_hostname, + proxy_uri.port, + Gem::UriFormatter.new(proxy_uri.user).unescape, + Gem::UriFormatter.new(proxy_uri.password).unescape, + ] + elsif no_proxy? hostname, no_proxy + net_http_args + [nil, nil] + else + net_http_args + end + end +end diff --git a/lib/rubygems/request/http_pool.rb b/lib/rubygems/request/http_pool.rb new file mode 100644 index 0000000000..468502ca6b --- /dev/null +++ b/lib/rubygems/request/http_pool.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +## +# A connection "pool" that only manages one connection for now. Provides +# thread safe `checkout` and `checkin` methods. The pool consists of one +# connection that corresponds to `http_args`. This class is private, do not +# use it. + +class Gem::Request::HTTPPool # :nodoc: + attr_reader :cert_files, :proxy_uri + + def initialize(http_args, cert_files, proxy_uri, pool_size) + @http_args = http_args + @cert_files = cert_files + @proxy_uri = proxy_uri + @pool_size = pool_size + + @queue = Thread::SizedQueue.new @pool_size + setup_queue + end + + def checkout + @queue.pop || make_connection + end + + def checkin(connection) + @queue.push connection + end + + def close_all + until @queue.empty? + if (connection = @queue.pop(true)) && connection.started? + connection.finish + end + end + + setup_queue + end + + private + + def make_connection + setup_connection Gem::Request::ConnectionPools.client.new(*@http_args) + end + + def setup_connection(connection) + connection.start + connection + end + + def setup_queue + @pool_size.times { @queue.push(nil) } + end +end diff --git a/lib/rubygems/request/https_pool.rb b/lib/rubygems/request/https_pool.rb new file mode 100644 index 0000000000..cb1d4b59b6 --- /dev/null +++ b/lib/rubygems/request/https_pool.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Gem::Request::HTTPSPool < Gem::Request::HTTPPool # :nodoc: + private + + def setup_connection(connection) + Gem::Request.configure_connection_for_https(connection, @cert_files) + super + end +end diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb new file mode 100644 index 0000000000..eb8b4658f3 --- /dev/null +++ b/lib/rubygems/request_set.rb @@ -0,0 +1,514 @@ +# frozen_string_literal: true + +require_relative "vendored_tsort" + +## +# A RequestSet groups a request to activate a set of dependencies. +# +# nokogiri = Gem::Dependency.new 'nokogiri', '~> 1.6' +# pg = Gem::Dependency.new 'pg', '~> 0.14' +# +# set = Gem::RequestSet.new nokogiri, pg +# +# requests = set.resolve +# +# p requests.map { |r| r.full_name } +# #=> ["nokogiri-1.6.0", "mini_portile-0.5.1", "pg-0.17.0"] + +class Gem::RequestSet + include Gem::TSort + + ## + # Array of gems to install even if already installed + + attr_accessor :always_install + + attr_reader :dependencies + + attr_accessor :development + + ## + # Errors fetching gems during resolution. + + attr_reader :errors + + ## + # Set to true if you want to install only direct development dependencies. + + attr_accessor :development_shallow + + ## + # The set of git gems imported via load_gemdeps. + + attr_reader :git_set # :nodoc: + + ## + # When true, dependency resolution is not performed, only the requested gems + # are installed. + + attr_accessor :ignore_dependencies + + attr_reader :install_dir # :nodoc: + + ## + # If true, allow dependencies to match prerelease gems. + + attr_accessor :prerelease + + ## + # When false no remote sets are used for resolving gems. + + attr_accessor :remote + + attr_reader :resolver # :nodoc: + + ## + # Sets used for resolution + + attr_reader :sets # :nodoc: + + ## + # Treat missing dependencies as silent errors + + attr_accessor :soft_missing + + ## + # The set of vendor gems imported via load_gemdeps. + + attr_reader :vendor_set # :nodoc: + + ## + # The set of source gems imported via load_gemdeps. + + attr_reader :source_set + + ## + # Creates a RequestSet for a list of Gem::Dependency objects, +deps+. You + # can then #resolve and #install the resolved list of dependencies. + # + # nokogiri = Gem::Dependency.new 'nokogiri', '~> 1.6' + # pg = Gem::Dependency.new 'pg', '~> 0.14' + # + # set = Gem::RequestSet.new nokogiri, pg + + def initialize(*deps) + @dependencies = deps + + @always_install = [] + @conservative = false + @dependency_names = {} + @development = false + @development_shallow = false + @errors = [] + @git_set = nil + @ignore_dependencies = false + @install_dir = Gem.dir + @prerelease = false + @remote = true + @requests = [] + @sets = [] + @soft_missing = false + @sorted_requests = nil + @specs = nil + @vendor_set = nil + @source_set = nil + + yield self if block_given? + end + + ## + # Declare that a gem of name +name+ with +reqs+ requirements is needed. + + def gem(name, *reqs) + if dep = @dependency_names[name] + dep.requirement.concat reqs + else + dep = Gem::Dependency.new name, *reqs + @dependency_names[name] = dep + @dependencies << dep + end + end + + ## + # Add +deps+ Gem::Dependency objects to the set. + + def import(deps) + @dependencies.concat deps + end + + ## + # Installs gems for this RequestSet using the Gem::Installer +options+. + # + # If a +block+ is given an activation +request+ and +installer+ are yielded. + # The +installer+ will be +nil+ if a gem matching the request was already + # installed. + + def install(options, &block) # :yields: request, installer + if dir = options[:install_dir] + requests = install_into dir, false, options, &block + return requests + end + + @prerelease = options[:prerelease] + + requests = [] + download_queue = Thread::Queue.new + + # Create a thread-safe list of gems to download + sorted_requests.each do |req| + download_queue << req + end + + # Create N threads in a pool, have them download all the gems + threads = Array.new(Gem.configuration.concurrent_downloads) do + # When a thread pops this item, it knows to stop running. The symbol + # is queued here so that there will be one symbol per thread. + download_queue << :stop + + Thread.new do + # The pop method will block waiting for items, so the only way + # to stop a thread from running is to provide a final item that + # means the thread should stop. + while req = download_queue.pop + break if req == :stop + req.spec.download options unless req.installed? + end + end + end + + # Wait for all the downloads to finish before continuing + threads.each(&:value) + + # Install requested gems after they have been downloaded + sorted_requests.each do |req| + if req.installed? && @always_install.none? {|spec| spec == req.spec.spec } + req.spec.spec.build_extensions unless options[:build_extension] == false + yield req, nil if block_given? + next + end + + spec = + begin + req.spec.install options do |installer| + yield req, installer if block_given? + end + rescue Gem::RuntimeRequirementNotMetError => e + suggestion = "There are no versions of #{req.request} compatible with your Ruby & RubyGems" + suggestion += ". Maybe try installing an older version of the gem you're looking for?" unless @always_install.include?(req.spec.spec) + e.suggestion = suggestion + raise + end + + requests << spec + end + + return requests if options[:gemdeps] + + install_hooks requests, options + + requests + end + + ## + # Installs from the gem dependencies files in the +:gemdeps+ option in + # +options+, yielding to the +block+ as in #install. + # + # If +:without_groups+ is given in the +options+, those groups in the gem + # dependencies file are not used. See Gem::Installer for other +options+. + + def install_from_gemdeps(options, &block) + gemdeps = options[:gemdeps] + + @install_dir = options[:install_dir] || Gem.dir + @prerelease = options[:prerelease] + @remote = options[:domain] != :local + @conservative = true if options[:conservative] + + gem_deps_api = load_gemdeps gemdeps, options[:without_groups], true + + resolve + + if options[:explain] + puts "Gems to install:" + + sorted_requests.each do |spec| + puts " #{spec.full_name}" + end + else + installed = install options, &block + + if options.fetch :lock, true + lockfile = + Gem::RequestSet::Lockfile.build self, gemdeps, gem_deps_api.dependencies + lockfile.write + end + + installed + end + end + + def install_into(dir, force = true, options = {}) + gem_home = ENV["GEM_HOME"] + ENV["GEM_HOME"] = dir + + existing = force ? [] : specs_in(dir) + existing.delete_if {|s| @always_install.include? s } + + dir = File.expand_path dir + + installed = [] + + options[:development] = false + options[:install_dir] = dir + options[:only_install_dir] = true + @prerelease = options[:prerelease] + + sorted_requests.each do |request| + spec = request.spec + + if existing.find {|s| s.full_name == spec.full_name } + yield request, nil if block_given? + next + end + + spec.install options do |installer| + yield request, installer if block_given? + end + + installed << request + end + + install_hooks installed, options + + installed + ensure + ENV["GEM_HOME"] = gem_home + end + + ## + # Call hooks on installed gems + + def install_hooks(requests, options) + specs = requests.map do |request| + case request + when Gem::Resolver::ActivationRequest then + request.spec.spec + else + request + end + end + + require_relative "dependency_installer" + inst = Gem::DependencyInstaller.new options + inst.installed_gems.replace specs + + Gem.done_installing_hooks.each do |hook| + hook.call inst, specs + end unless Gem.done_installing_hooks.empty? + end + + ## + # Load a dependency management file. + + def load_gemdeps(path, without_groups = [], installing = false) + @git_set = Gem::Resolver::GitSet.new + @vendor_set = Gem::Resolver::VendorSet.new + @source_set = Gem::Resolver::SourceSet.new + + @git_set.root_dir = @install_dir + + lock_file = "#{File.expand_path(path)}.lock" + if File.exist?(lock_file) + load_lockfile lock_file + end + + gf = Gem::RequestSet::GemDependencyAPI.new self, path + gf.installing = installing + gf.without_groups = without_groups if without_groups + gf.load + end + + def load_lockfile(lock_file) # :nodoc: + require "bundler" + require "bundler/lockfile_parser" + + # Bundler::Source::Path resolves relative `remote:` paths against + # Bundler.root, which raises when there is no Gemfile in the working + # directory. Anchor it to the lockfile's directory so PATH sections in a + # `gem install -g` lockfile can be parsed without a Bundler environment. + previous_root = Bundler.instance_variable_get(:@root) + Bundler.instance_variable_set(:@root, Pathname.new(File.expand_path(File.dirname(lock_file)))) + + parser = Bundler::LockfileParser.new(File.read(lock_file), lockfile_path: lock_file) + + parser.specs.group_by(&:source).each do |source, specs| + case source + when Bundler::Source::Rubygems + remotes = source.remotes.map {|remote| Gem::Source.new(remote.to_s) } + remotes << Gem::Source.new(Gem::DEFAULT_HOST) if remotes.empty? + lock_set = Gem::Resolver::LockSet.new(remotes) + specs.each do |spec| + added = lock_set.add(spec.name, spec.version.to_s, spec.platform) + spec.dependencies.each do |dep| + added.each {|s| s.add_dependency dep } + end + end + @sets << lock_set + when Bundler::Source::Git + git_set = Gem::Resolver::GitSet.new + git_set.root_dir = @install_dir + specs.each do |spec| + git_spec = git_set.add_git_spec( + spec.name, + spec.version.to_s, + source.uri.to_s, + source.revision, + source.submodules || false + ) + spec.dependencies.each {|dep| git_spec.add_dependency dep } + end + @sets << git_set + when Bundler::Source::Path + vendor_set = Gem::Resolver::VendorSet.new + specs.each do |spec| + loaded = vendor_set.add_vendor_gem(spec.name, source.path.to_s) + spec.dependencies.each {|dep| loaded.dependencies << dep } + end + @sets << vendor_set + end + end + + parser.dependencies.each_value do |dep| + gem dep.name, *dep.requirement.as_list + end + ensure + Bundler.instance_variable_set(:@root, previous_root) if defined?(previous_root) + end + + def pretty_print(q) # :nodoc: + q.group 2, "[RequestSet:", "]" do + q.breakable + + if @remote + q.text "remote" + q.breakable + end + + if @prerelease + q.text "prerelease" + q.breakable + end + + if @development_shallow + q.text "shallow development" + q.breakable + elsif @development + q.text "development" + q.breakable + end + + if @soft_missing + q.text "soft missing" + end + + q.group 2, "[dependencies:", "]" do + q.breakable + @dependencies.map do |dep| + q.text dep.to_s + q.breakable + end + end + + q.breakable + q.text "sets:" + + q.breakable + q.pp @sets.map(&:class) + end + end + + ## + # Resolve the requested dependencies and return an Array of Specification + # objects to be activated. + + def resolve(set = Gem::Resolver::BestSet.new) + @sets << set + @sets << @git_set + @sets << @vendor_set + @sets << @source_set + + set = Gem::Resolver.compose_sets(*@sets) + set.remote = @remote + set.prerelease = @prerelease + + resolver = Gem::Resolver.new @dependencies, set + resolver.development = @development + resolver.development_shallow = @development_shallow + resolver.ignore_dependencies = @ignore_dependencies + resolver.soft_missing = @soft_missing + + if @conservative + installed_gems = {} + Gem::Specification.find_all do |spec| + (installed_gems[spec.name] ||= []) << spec + end + resolver.skip_gems = installed_gems + end + + @resolver = resolver + + @requests = resolver.resolve + + @errors = set.errors + + @requests + end + + ## + # Resolve the requested dependencies against the gems available via Gem.path + # and return an Array of Specification objects to be activated. + + def resolve_current + resolve Gem::Resolver::CurrentSet.new + end + + def sorted_requests + @sorted_requests ||= strongly_connected_components.flatten + end + + def specs + @specs ||= @requests.map(&:full_spec) + end + + def specs_in(dir) + Gem::Util.glob_files_in_dir("*.gemspec", File.join(dir, "specifications")).map do |g| + Gem::Specification.load g + end + end + + def tsort_each_node(&block) # :nodoc: + @requests.each(&block) + end + + def tsort_each_child(node) # :nodoc: + node.spec.dependencies.each do |dep| + next if dep.type == :development && !@development + + match = @requests.find do |r| + dep.match?(r.spec.name, r.spec.version, r.spec.is_a?(Gem::Resolver::InstalledSpecification) || @prerelease) + end + + unless match + next if dep.type == :development && @development_shallow + next if @soft_missing + raise Gem::DependencyError, + "Unresolved dependency found during sorting - #{dep} (requested by #{node.spec.full_name})" + end + + yield match + end + end +end + +require_relative "request_set/gem_dependency_api" +require_relative "request_set/lockfile" diff --git a/lib/rubygems/request_set/gem_dependency_api.rb b/lib/rubygems/request_set/gem_dependency_api.rb new file mode 100644 index 0000000000..99d96f928b --- /dev/null +++ b/lib/rubygems/request_set/gem_dependency_api.rb @@ -0,0 +1,841 @@ +# frozen_string_literal: true + +## +# A semi-compatible DSL for the Bundler Gemfile and Isolate gem dependencies +# files. +# +# To work with both the Bundler Gemfile and Isolate formats this +# implementation takes some liberties to allow compatibility with each, most +# notably in #source. +# +# A basic gem dependencies file will look like the following: +# +# source 'https://rubygems.org' +# +# gem 'rails', '3.2.14a +# gem 'devise', '~> 2.1', '>= 2.1.3' +# gem 'cancan' +# gem 'airbrake' +# gem 'pg' +# +# RubyGems recommends saving this as gem.deps.rb over Gemfile or Isolate. +# +# To install the gems in this Gemfile use `gem install -g` to install it and +# create a lockfile. The lockfile will ensure that when you make changes to +# your gem dependencies file a minimum amount of change is made to the +# dependencies of your gems. +# +# RubyGems can activate all the gems in your dependencies file at startup +# using the RUBYGEMS_GEMDEPS environment variable or through Gem.use_gemdeps. +# See Gem.use_gemdeps for details and warnings. +# +# See `gem help install` and `gem help gem_dependencies` for further details. + +class Gem::RequestSet::GemDependencyAPI + ENGINE_MAP = { # :nodoc: + jruby: %w[jruby], + jruby_18: %w[jruby], + jruby_19: %w[jruby], + maglev: %w[maglev], + mri: %w[ruby], + mri_18: %w[ruby], + mri_19: %w[ruby], + mri_20: %w[ruby], + mri_21: %w[ruby], + rbx: %w[rbx], + truffleruby: %w[truffleruby], + ruby: %w[ruby rbx maglev truffleruby], + ruby_18: %w[ruby rbx maglev truffleruby], + ruby_19: %w[ruby rbx maglev truffleruby], + ruby_20: %w[ruby rbx maglev truffleruby], + ruby_21: %w[ruby rbx maglev truffleruby], + }.freeze + + mswin = Gem::Platform.new "x86-mswin32" + mswin64 = Gem::Platform.new "x64-mswin64" + x86_mingw = Gem::Platform.new "x86-mingw32" + x64_mingw = Gem::Platform.new "x64-mingw32" + + PLATFORM_MAP = { # :nodoc: + jruby: Gem::Platform::RUBY, + jruby_18: Gem::Platform::RUBY, + jruby_19: Gem::Platform::RUBY, + maglev: Gem::Platform::RUBY, + mingw: x86_mingw, + mingw_18: x86_mingw, + mingw_19: x86_mingw, + mingw_20: x86_mingw, + mingw_21: x86_mingw, + mri: Gem::Platform::RUBY, + mri_18: Gem::Platform::RUBY, + mri_19: Gem::Platform::RUBY, + mri_20: Gem::Platform::RUBY, + mri_21: Gem::Platform::RUBY, + mswin: mswin, + mswin_18: mswin, + mswin_19: mswin, + mswin_20: mswin, + mswin_21: mswin, + mswin64: mswin64, + mswin64_19: mswin64, + mswin64_20: mswin64, + mswin64_21: mswin64, + rbx: Gem::Platform::RUBY, + ruby: Gem::Platform::RUBY, + ruby_18: Gem::Platform::RUBY, + ruby_19: Gem::Platform::RUBY, + ruby_20: Gem::Platform::RUBY, + ruby_21: Gem::Platform::RUBY, + truffleruby: Gem::Platform::RUBY, + x64_mingw: x64_mingw, + x64_mingw_20: x64_mingw, + x64_mingw_21: x64_mingw, + }.freeze + + gt_eq_0 = Gem::Requirement.new ">= 0" + tilde_gt_1_8_0 = Gem::Requirement.new "~> 1.8.0" + tilde_gt_1_9_0 = Gem::Requirement.new "~> 1.9.0" + tilde_gt_2_0_0 = Gem::Requirement.new "~> 2.0.0" + tilde_gt_2_1_0 = Gem::Requirement.new "~> 2.1.0" + + VERSION_MAP = { # :nodoc: + jruby: gt_eq_0, + jruby_18: tilde_gt_1_8_0, + jruby_19: tilde_gt_1_9_0, + maglev: gt_eq_0, + mingw: gt_eq_0, + mingw_18: tilde_gt_1_8_0, + mingw_19: tilde_gt_1_9_0, + mingw_20: tilde_gt_2_0_0, + mingw_21: tilde_gt_2_1_0, + mri: gt_eq_0, + mri_18: tilde_gt_1_8_0, + mri_19: tilde_gt_1_9_0, + mri_20: tilde_gt_2_0_0, + mri_21: tilde_gt_2_1_0, + mswin: gt_eq_0, + mswin_18: tilde_gt_1_8_0, + mswin_19: tilde_gt_1_9_0, + mswin_20: tilde_gt_2_0_0, + mswin_21: tilde_gt_2_1_0, + mswin64: gt_eq_0, + mswin64_19: tilde_gt_1_9_0, + mswin64_20: tilde_gt_2_0_0, + mswin64_21: tilde_gt_2_1_0, + rbx: gt_eq_0, + ruby: gt_eq_0, + ruby_18: tilde_gt_1_8_0, + ruby_19: tilde_gt_1_9_0, + ruby_20: tilde_gt_2_0_0, + ruby_21: tilde_gt_2_1_0, + truffleruby: gt_eq_0, + x64_mingw: gt_eq_0, + x64_mingw_20: tilde_gt_2_0_0, + x64_mingw_21: tilde_gt_2_1_0, + }.freeze + + WINDOWS = { # :nodoc: + mingw: :only, + mingw_18: :only, + mingw_19: :only, + mingw_20: :only, + mingw_21: :only, + mri: :never, + mri_18: :never, + mri_19: :never, + mri_20: :never, + mri_21: :never, + mswin: :only, + mswin_18: :only, + mswin_19: :only, + mswin_20: :only, + mswin_21: :only, + mswin64: :only, + mswin64_19: :only, + mswin64_20: :only, + mswin64_21: :only, + rbx: :never, + ruby: :never, + ruby_18: :never, + ruby_19: :never, + ruby_20: :never, + ruby_21: :never, + x64_mingw: :only, + x64_mingw_20: :only, + x64_mingw_21: :only, + }.freeze + + ## + # The gems required by #gem statements in the gem.deps.rb file + + attr_reader :dependencies + + ## + # A set of gems that are loaded via the +:git+ option to #gem + + attr_reader :git_set # :nodoc: + + ## + # A Hash containing gem names and files to require from those gems. + + attr_reader :requires + + ## + # A set of gems that are loaded via the +:path+ option to #gem + + attr_reader :vendor_set # :nodoc: + + ## + # The groups of gems to exclude from installation + + attr_accessor :without_groups # :nodoc: + + ## + # Creates a new GemDependencyAPI that will add dependencies to the + # Gem::RequestSet +set+ based on the dependency API description in +path+. + + def initialize(set, path) + @set = set + @path = path + + @current_groups = nil + @current_platforms = nil + @current_repository = nil + @dependencies = {} + @default_sources = true + @git_set = @set.git_set + @git_sources = {} + @installing = false + @requires = Hash.new {|h, name| h[name] = [] } + @vendor_set = @set.vendor_set + @source_set = @set.source_set + @gem_sources = {} + @without_groups = [] + + git_source :github do |repo_name| + repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include? "/" + + "https://github.com/#{repo_name}.git" + end + + git_source :bitbucket do |repo_name| + repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include? "/" + + user, = repo_name.split "/", 2 + + "https://#{user}@bitbucket.org/#{repo_name}.git" + end + end + + ## + # Adds +dependencies+ to the request set if any of the +groups+ are allowed. + # This is used for gemspec dependencies. + + def add_dependencies(groups, dependencies) # :nodoc: + return unless (groups & @without_groups).empty? + + dependencies.each do |dep| + @set.gem dep.name, *dep.requirement.as_list + end + end + + private :add_dependencies + + ## + # Finds a gemspec with the given +name+ that lives at +path+. + + def find_gemspec(name, path) # :nodoc: + glob = File.join path, "#{name}.gemspec" + + spec_files = Dir[glob] + + case spec_files.length + when 1 then + spec_file = spec_files.first + + spec = Gem::Specification.load spec_file + + return spec if spec + + raise ArgumentError, "invalid gemspec #{spec_file}" + when 0 then + raise ArgumentError, "no gemspecs found at #{Dir.pwd}" + else + raise ArgumentError, + "found multiple gemspecs at #{Dir.pwd}, " \ + "use the name: option to specify the one you want" + end + end + + ## + # Changes the behavior of gem dependency file loading to installing mode. + # In installing mode certain restrictions are ignored such as ruby version + # mismatch checks. + + def installing=(installing) # :nodoc: + @installing = installing + end + + ## + # Loads the gem dependency file and returns self. + + def load + instance_eval File.read(@path), @path, 1 + + self + end + + ## + # :category: Gem Dependencies DSL + # + # :call-seq: + # gem(name) + # gem(name, *requirements) + # gem(name, *requirements, options) + # + # Specifies a gem dependency with the given +name+ and +requirements+. You + # may also supply +options+ following the +requirements+ + # + # +options+ include: + # + # require: :: + # RubyGems does not provide any autorequire features so requires in a gem + # dependencies file are recorded but ignored. + # + # In bundler the require: option overrides the file to require during + # Bundler.require. By default the name of the dependency is required in + # Bundler. A single file or an Array of files may be given. + # + # To disable requiring any file give +false+: + # + # gem 'rake', require: false + # + # group: :: + # Place the dependencies in the given dependency group. A single group or + # an Array of groups may be given. + # + # See also #group + # + # platform: :: + # Only install the dependency on the given platform. A single platform or + # an Array of platforms may be given. + # + # See #platform for a list of platforms available. + # + # path: :: + # Install this dependency from an unpacked gem in the given directory. + # + # gem 'modified_gem', path: 'vendor/modified_gem' + # + # git: :: + # Install this dependency from a git repository: + # + # gem 'private_gem', git: 'git@my.company.example:private_gem.git' + # + # gist: :: + # Install this dependency from the gist ID: + # + # gem 'bang', gist: '1232884' + # + # github: :: + # Install this dependency from a github git repository: + # + # gem 'private_gem', github: 'my_company/private_gem' + # + # submodules: :: + # Set to +true+ to include submodules when fetching the git repository for + # git:, gist: and github: dependencies. + # + # ref: :: + # Use the given commit name or SHA for git:, gist: and github: + # dependencies. + # + # branch: :: + # Use the given branch for git:, gist: and github: dependencies. + # + # tag: :: + # Use the given tag for git:, gist: and github: dependencies. + + def gem(name, *requirements) + options = requirements.pop if requirements.last.is_a?(Hash) + options ||= {} + + options[:git] = @current_repository if @current_repository + + source_set = false + + source_set ||= gem_path name, options + source_set ||= gem_git name, options + source_set ||= gem_git_source name, options + source_set ||= gem_source name, options + + duplicate = @dependencies.include? name + + @dependencies[name] = + if requirements.empty? && !source_set + Gem::Requirement.default + elsif source_set + Gem::Requirement.source_set + else + Gem::Requirement.create requirements + end + + return unless gem_platforms name, options + + groups = gem_group name, options + + return unless (groups & @without_groups).empty? + + pin_gem_source name, :default unless source_set + + gem_requires name, options + + if duplicate + warn <<-WARNING +Gem dependencies file #{@path} requires #{name} more than once. + WARNING + end + + @set.gem name, *requirements + end + + ## + # Handles the git: option from +options+ for gem +name+. + # + # Returns +true+ if the gist or git option was handled. + + def gem_git(name, options) # :nodoc: + if gist = options.delete(:gist) + options[:git] = "https://gist.github.com/#{gist}.git" + end + + return unless repository = options.delete(:git) + + pin_gem_source name, :git, repository + + reference = gem_git_reference options + + submodules = options.delete :submodules + + @git_set.add_git_gem name, repository, reference, submodules + + true + end + + ## + # Handles the git options from +options+ for git gem. + # + # Returns reference for the git gem. + + def gem_git_reference(options) # :nodoc: + ref = options.delete :ref + branch = options.delete :branch + tag = options.delete :tag + + reference = nil + reference ||= ref + reference ||= branch + reference ||= tag + + if ref && branch + warn <<-WARNING +Gem dependencies file #{@path} includes git reference for both ref and branch but only ref is used. + WARNING + end + if (ref || branch) && tag + warn <<-WARNING +Gem dependencies file #{@path} includes git reference for both ref/branch and tag but only ref/branch is used. + WARNING + end + + reference + end + + private :gem_git + + ## + # Handles a git gem option from +options+ for gem +name+ for a git source + # registered through git_source. + # + # Returns +true+ if the custom source option was handled. + + def gem_git_source(name, options) # :nodoc: + return unless git_source = (@git_sources.keys & options.keys).last + + source_callback = @git_sources[git_source] + source_param = options.delete git_source + + git_url = source_callback.call source_param + + options[:git] = git_url + + gem_git name, options + + true + end + + private :gem_git_source + + ## + # Handles the :group and :groups +options+ for the gem with the given + # +name+. + + def gem_group(name, options) # :nodoc: + g = options.delete :group + all_groups = g ? Array(g) : [] + + groups = options.delete :groups + all_groups |= groups if groups + + all_groups |= @current_groups if @current_groups + + all_groups + end + + private :gem_group + + ## + # Handles the path: option from +options+ for gem +name+. + # + # Returns +true+ if the path option was handled. + + def gem_path(name, options) # :nodoc: + return unless directory = options.delete(:path) + + pin_gem_source name, :path, directory + + @vendor_set.add_vendor_gem name, directory + + true + end + + private :gem_path + + ## + # Handles the source: option from +options+ for gem +name+. + # + # Returns +true+ if the source option was handled. + + def gem_source(name, options) # :nodoc: + return unless source = options.delete(:source) + + pin_gem_source name, :source, source + + @source_set.add_source_gem name, source + + true + end + + private :gem_source + + ## + # Handles the platforms: option from +options+. Returns true if the + # platform matches the current platform. + + def gem_platforms(name, options) # :nodoc: + platform_names = Array(options.delete(:platform)) + platform_names.concat Array(options.delete(:platforms)) + platform_names.concat @current_platforms if @current_platforms + + return true if platform_names.empty? + + platform_names.any? do |platform_name| + raise ArgumentError, "unknown platform #{platform_name.inspect}" unless + platform = PLATFORM_MAP[platform_name] + + next false unless Gem::Platform.match_gem? platform, name + + if engines = ENGINE_MAP[platform_name] + next false unless engines.include? Gem.ruby_engine + end + + case WINDOWS[platform_name] + when :only then + next false unless Gem.win_platform? + when :never then + next false if Gem.win_platform? + end + + VERSION_MAP[platform_name].satisfied_by? Gem.ruby_version + end + end + + private :gem_platforms + + ## + # Records the require: option from +options+ and adds those files, or the + # default file to the require list for +name+. + + def gem_requires(name, options) # :nodoc: + if options.include? :require + if requires = options.delete(:require) + @requires[name].concat Array requires + end + else + @requires[name] << name + end + raise ArgumentError, "Unhandled gem options #{options.inspect}" unless options.empty? + end + + private :gem_requires + + ## + # :category: Gem Dependencies DSL + # + # Block form for specifying gems from a git +repository+. + # + # git 'https://github.com/rails/rails.git' do + # gem 'activesupport' + # gem 'activerecord' + # end + + def git(repository) + @current_repository = repository + + yield + ensure + @current_repository = nil + end + + ## + # Defines a custom git source that uses +name+ to expand git repositories + # for use in gems built from git repositories. You must provide a block + # that accepts a git repository name for expansion. + + def git_source(name, &callback) + @git_sources[name] = callback + end + + ## + # Returns the basename of the file the dependencies were loaded from + + def gem_deps_file # :nodoc: + File.basename @path + end + + ## + # :category: Gem Dependencies DSL + # + # Loads dependencies from a gemspec file. + # + # +options+ include: + # + # name: :: + # The name portion of the gemspec file. Defaults to searching for any + # gemspec file in the current directory. + # + # gemspec name: 'my_gem' + # + # path: :: + # The path the gemspec lives in. Defaults to the current directory: + # + # gemspec 'my_gem', path: 'gemspecs', name: 'my_gem' + # + # development_group: :: + # The group to add development dependencies to. By default this is + # :development. Only one group may be specified. + + def gemspec(options = {}) + name = options.delete(:name) || "{,*}" + path = options.delete(:path) || "." + development_group = options.delete(:development_group) || :development + + spec = find_gemspec name, path + + groups = gem_group spec.name, {} + + self_dep = Gem::Dependency.new spec.name, spec.version + + add_dependencies groups, [self_dep] + add_dependencies groups, spec.runtime_dependencies + + @dependencies[spec.name] = Gem::Requirement.source_set + + spec.dependencies.each do |dep| + @dependencies[dep.name] = dep.requirement + end + + groups << development_group + + add_dependencies groups, spec.development_dependencies + + @vendor_set.add_vendor_gem spec.name, path + gem_requires spec.name, options + end + + ## + # :category: Gem Dependencies DSL + # + # Block form for placing a dependency in the given +groups+. + # + # group :development do + # gem 'debugger' + # end + # + # group :development, :test do + # gem 'minitest' + # end + # + # Groups can be excluded at install time using `gem install -g --without + # development`. See `gem help install` and `gem help gem_dependencies` for + # further details. + + def group(*groups) + @current_groups = groups + + yield + ensure + @current_groups = nil + end + + ## + # Pins the gem +name+ to the given +source+. Adding a gem with the same + # name from a different +source+ will raise an exception. + + def pin_gem_source(name, type = :default, source = nil) + source_description = + case type + when :default then "(default)" + when :path then "path: #{source}" + when :git then "git: #{source}" + when :source then "source: #{source}" + else "(unknown)" + end + + raise ArgumentError, + "duplicate source #{source_description} for gem #{name}" if + @gem_sources.fetch(name, source) != source + + @gem_sources[name] = source + end + + private :pin_gem_source + + ## + # :category: Gem Dependencies DSL + # + # Block form for restricting gems to a set of platforms. + # + # The gem dependencies platform is different from Gem::Platform. A platform + # gem.deps.rb platform matches on the ruby engine, the ruby version and + # whether or not windows is allowed. + # + # :ruby, :ruby_XY :: + # Matches non-windows, non-jruby implementations where X and Y can be used + # to match releases in the 1.8, 1.9, 2.0 or 2.1 series. + # + # :mri, :mri_XY :: + # Matches non-windows C Ruby (Matz Ruby) or only the 1.8, 1.9, 2.0 or + # 2.1 series. + # + # :mingw, :mingw_XY :: + # Matches 32 bit C Ruby on MinGW or only the 1.8, 1.9, 2.0 or 2.1 series. + # + # :x64_mingw, :x64_mingw_XY :: + # Matches 64 bit C Ruby on MinGW or only the 1.8, 1.9, 2.0 or 2.1 series. + # + # :mswin, :mswin_XY :: + # Matches 32 bit C Ruby on Microsoft Windows or only the 1.8, 1.9, 2.0 or + # 2.1 series. + # + # :mswin64, :mswin64_XY :: + # Matches 64 bit C Ruby on Microsoft Windows or only the 1.8, 1.9, 2.0 or + # 2.1 series. + # + # :jruby, :jruby_XY :: + # Matches JRuby or JRuby in 1.8 or 1.9 mode. + # + # :maglev :: + # Matches Maglev + # + # :rbx :: + # Matches non-windows Rubinius + # + # NOTE: There is inconsistency in what environment a platform matches. You + # may need to read the source to know the exact details. + + def platform(*platforms) + @current_platforms = platforms + + yield + ensure + @current_platforms = nil + end + + ## + # :category: Gem Dependencies DSL + # + # Block form for restricting gems to a particular set of platforms. See + # #platform. + + alias_method :platforms, :platform + + ## + # :category: Gem Dependencies DSL + # + # Restricts this gem dependencies file to the given ruby +version+. + # + # You may also provide +engine:+ and +engine_version:+ options to restrict + # this gem dependencies file to a particular ruby engine and its engine + # version. This matching is performed by using the RUBY_ENGINE and + # RUBY_ENGINE_VERSION constants. + + def ruby(version, options = {}) + engine = options[:engine] + engine_version = options[:engine_version] + + raise ArgumentError, + "You must specify engine_version along with the Ruby engine" if + engine && !engine_version + + return true if @installing + + unless version == RUBY_VERSION + message = "Your Ruby version is #{RUBY_VERSION}, " \ + "but your #{gem_deps_file} requires #{version}" + + raise Gem::RubyVersionMismatch, message + end + + if engine && engine != Gem.ruby_engine + message = "Your Ruby engine is #{Gem.ruby_engine}, " \ + "but your #{gem_deps_file} requires #{engine}" + + raise Gem::RubyVersionMismatch, message + end + + if engine_version + if engine_version != RUBY_ENGINE_VERSION + message = + "Your Ruby engine version is #{Gem.ruby_engine} #{RUBY_ENGINE_VERSION}, " \ + "but your #{gem_deps_file} requires #{engine} #{engine_version}" + + raise Gem::RubyVersionMismatch, message + end + end + + true + end + + ## + # :category: Gem Dependencies DSL + # + # Sets +url+ as a source for gems for this dependency API. RubyGems uses + # the default configured sources if no source was given. If a source is set + # only that source is used. + # + # This method differs in behavior from Bundler: + # + # * The +:gemcutter+, # +:rubygems+ and +:rubyforge+ sources are not + # supported as they are deprecated in bundler. + # * The +prepend:+ option is not supported. If you wish to order sources + # then list them in your preferred order. + + def source(url) + Gem.sources.clear if @default_sources + + @default_sources = false + + Gem.sources << url + end +end diff --git a/lib/rubygems/request_set/lockfile.rb b/lib/rubygems/request_set/lockfile.rb new file mode 100644 index 0000000000..8b9c9690d6 --- /dev/null +++ b/lib/rubygems/request_set/lockfile.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +## +# Parses a gem.deps.rb.lock file and constructs a LockSet containing the +# dependencies found inside. If the lock file is missing no LockSet is +# constructed. + +class Gem::RequestSet::Lockfile + ## + # Raised when a lockfile cannot be parsed + + class ParseError < Gem::Exception + ## + # The column where the error was encountered + + attr_reader :column + + ## + # The line where the error was encountered + + attr_reader :line + + ## + # The location of the lock file + + attr_reader :path + + ## + # Raises a ParseError with the given +message+ which was encountered at a + # +line+ and +column+ while parsing. + + def initialize(message, column, line, path) + @line = line + @column = column + @path = path + super "#{message} (at line #{line} column #{column})" + end + end + + ## + # Creates a new Lockfile for the given Gem::RequestSet and +gem_deps_file+ + # location. + + def self.build(request_set, gem_deps_file, dependencies = nil) + request_set.resolve + dependencies ||= requests_to_deps request_set.sorted_requests + new request_set, gem_deps_file, dependencies + end + + def self.requests_to_deps(requests) # :nodoc: + deps = {} + + requests.each do |request| + spec = request.spec + name = request.name + requirement = request.request.dependency.requirement + + deps[name] = if [Gem::Resolver::VendorSpecification, + Gem::Resolver::GitSpecification].include? spec.class + Gem::Requirement.source_set + else + requirement + end + end + + deps + end + + ## + # The platforms for this Lockfile + + attr_reader :platforms + + def initialize(request_set, gem_deps_file, dependencies) + @set = request_set + @dependencies = dependencies + @gem_deps_file = File.expand_path(gem_deps_file) + @gem_deps_dir = File.dirname(@gem_deps_file) + @platforms = [] + end + + def add_DEPENDENCIES(out) # :nodoc: + out << "DEPENDENCIES" + + out.concat @dependencies.sort.map {|name, requirement| + " #{name}#{requirement.for_lockfile}" + } + + out << nil + end + + def add_GEM(out, spec_groups) # :nodoc: + return if spec_groups.empty? + + source_groups = spec_groups.values.flatten.group_by do |request| + request.spec.source.uri + end + + source_groups.sort_by {|group,| group.to_s }.map do |group, requests| + out << "GEM" + out << " remote: #{group}" + out << " specs:" + + requests.sort_by(&:name).each do |request| + next if request.spec.name == "bundler" + platform = "-#{request.spec.platform}" unless + request.spec.platform == Gem::Platform::RUBY + + out << " #{request.name} (#{request.version}#{platform})" + + request.full_spec.dependencies.sort.each do |dependency| + next if dependency.type == :development + + requirement = dependency.requirement + out << " #{dependency.name}#{requirement.for_lockfile}" + end + end + out << nil + end + end + + def add_GIT(out, git_requests) + return if git_requests.empty? + + by_repository_revision = git_requests.group_by do |request| + source = request.spec.source + [source.repository, source.rev_parse] + end + + by_repository_revision.each do |(repository, revision), requests| + out << "GIT" + out << " remote: #{repository}" + out << " revision: #{revision}" + out << " specs:" + + requests.sort_by(&:name).each do |request| + out << " #{request.name} (#{request.version})" + + dependencies = request.spec.dependencies.sort_by(&:name) + dependencies.each do |dep| + out << " #{dep.name}#{dep.requirement.for_lockfile}" + end + end + out << nil + end + end + + def relative_path_from(dest, base) # :nodoc: + dest = File.expand_path(dest) + base = File.expand_path(base) + + if dest.index(base) == 0 + offset = dest[base.size + 1..-1] + + return "." unless offset + + offset + else + dest + end + end + + def add_PATH(out, path_requests) # :nodoc: + return if path_requests.empty? + + out << "PATH" + path_requests.each do |request| + directory = File.expand_path(request.spec.source.uri) + + out << " remote: #{relative_path_from directory, @gem_deps_dir}" + out << " specs:" + out << " #{request.name} (#{request.version})" + end + + out << nil + end + + def add_PLATFORMS(out) # :nodoc: + out << "PLATFORMS" + + platforms = requests.map {|request| request.spec.platform }.uniq + + platforms = platforms.sort_by(&:to_s) + + platforms.each do |platform| + out << " #{platform}" + end + + out << nil + end + + def spec_groups + requests.group_by {|request| request.spec.class } + end + + ## + # The contents of the lock file. + + def to_s + out = [] + + groups = spec_groups + + add_PATH out, groups.delete(Gem::Resolver::VendorSpecification) { [] } + + add_GIT out, groups.delete(Gem::Resolver::GitSpecification) { [] } + + add_GEM out, groups + + add_PLATFORMS out + + add_DEPENDENCIES out + + out.join "\n" + end + + ## + # Writes the lock file alongside the gem dependencies file + + def write + content = to_s + + File.open "#{@gem_deps_file}.lock", "w" do |io| + io.write content + end + end + + private + + def requests + @set.sorted_requests + end +end diff --git a/lib/rubygems/requirement.rb b/lib/rubygems/requirement.rb new file mode 100644 index 0000000000..0d3f98eb0f --- /dev/null +++ b/lib/rubygems/requirement.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require_relative "version" + +## +# A Requirement is a set of one or more version restrictions. It supports a +# few (<tt>=, !=, >, <, >=, <=, ~></tt>) different restriction operators. +# +# See Gem::Version for a description on how versions and requirements work +# together in RubyGems. + +class Gem::Requirement + OPS = { # :nodoc: + "=" => lambda {|v, r| v == r }, + "!=" => lambda {|v, r| v != r }, + ">" => lambda {|v, r| v > r }, + "<" => lambda {|v, r| v < r }, + ">=" => lambda {|v, r| v >= r }, + "<=" => lambda {|v, r| v <= r }, + "~>" => lambda {|v, r| v >= r && v.release < r.bump }, + }.freeze + + SOURCE_SET_REQUIREMENT = Struct.new(:for_lockfile).new "!" # :nodoc: + + quoted = Regexp.union(OPS.keys) + PATTERN_RAW = "\\s*(#{quoted})?\\s*(#{Gem::Version::VERSION_PATTERN})\\s*".freeze # :nodoc: + + ## + # A regular expression that matches a requirement + + PATTERN = /\A#{PATTERN_RAW}\z/ + + ## + # The default requirement matches any non-prerelease version + + DefaultRequirement = [">=", Gem::Version.new(0)].freeze + + ## + # The default requirement matches any version + + DefaultPrereleaseRequirement = [">=", Gem::Version.new("0.a")].freeze + + ## + # Raised when a bad requirement is encountered + + class BadRequirementError < ArgumentError; end + + ## + # Factory method to create a Gem::Requirement object. Input may be + # a Version, a String, or nil. Intended to simplify client code. + # + # If the input is "weird", the default version requirement is + # returned. + + def self.create(*inputs) + return new inputs if inputs.length > 1 + + input = inputs.shift + + case input + when Gem::Requirement then + input + when Gem::Version, Array then + new input + when "!" then + source_set + else + if input.respond_to? :to_str + new [input.to_str] + else + default + end + end + end + + def self.default + new ">= 0" + end + + def self.default_prerelease + new ">= 0.a" + end + + ### + # A source set requirement, used for Gemfiles and lockfiles + + def self.source_set # :nodoc: + SOURCE_SET_REQUIREMENT + end + + ## + # Parse +obj+, returning an <tt>[op, version]</tt> pair. +obj+ can + # be a String or a Gem::Version. + # + # If +obj+ is a String, it can be either a full requirement + # specification, like <tt>">= 1.2"</tt>, or a simple version number, + # like <tt>"1.2"</tt>. + # + # parse("> 1.0") # => [">", Gem::Version.new("1.0")] + # parse("1.0") # => ["=", Gem::Version.new("1.0")] + # parse(Gem::Version.new("1.0")) # => ["=, Gem::Version.new("1.0")] + + def self.parse(obj) + return ["=", obj] if Gem::Version === obj + + unless PATTERN =~ obj.to_s + raise BadRequirementError, "Illformed requirement [#{obj.inspect}]" + end + op = -($1 || "=") + version = -$2 + + if op == ">=" && version == "0" + DefaultRequirement + elsif op == ">=" && version == "0.a" + DefaultPrereleaseRequirement + else + [op, Gem::Version.new(version)] + end + end + + ## + # An array of requirement pairs. The first element of the pair is + # the op, and the second is the Gem::Version. + + attr_reader :requirements # :nodoc: + + ## + # Constructs a requirement from +requirements+. Requirements can be + # Strings, Gem::Versions, or Arrays of those. +nil+ and duplicate + # requirements are ignored. An empty set of +requirements+ is the + # same as <tt>">= 0"</tt>. + + def initialize(*requirements) + requirements = requirements.flatten + requirements.compact! + requirements.uniq! + + if requirements.empty? + @requirements = [DefaultRequirement] + else + @requirements = requirements.map! {|r| self.class.parse r } + end + end + + ## + # Concatenates the +new+ requirements onto this requirement. + + def concat(new) + new = new.flatten + new.compact! + new.uniq! + new = new.map {|r| self.class.parse r } + + @requirements.concat new + end + + ## + # Formats this requirement for use in a Gem::RequestSet::Lockfile. + + def for_lockfile # :nodoc: + return if @requirements == [DefaultRequirement] + + list = requirements.sort_by do |_, version| + version + end.map do |op, version| + "#{op} #{version}" + end.uniq + + " (#{list.join ", "})" + end + + ## + # true if this gem has no requirements. + + def none? + if @requirements.size == 1 + @requirements[0] == DefaultRequirement + else + false + end + end + + ## + # true if the requirement is for only an exact version + + def exact? + return false unless @requirements.size == 1 + @requirements[0][0] == "=" + end + + def as_list # :nodoc: + requirements.map {|op, version| "#{op} #{version}" } + end + + def hash # :nodoc: + requirements.map {|r| r.first == "~>" ? [r[0], r[1].to_s] : r }.sort.hash + end + + def marshal_dump # :nodoc: + [@requirements] + end + + def marshal_load(array) # :nodoc: + @requirements = array[0] + + raise TypeError, "wrong @requirements" unless Array === @requirements && + @requirements.all? {|r| r.size == 2 && (r.first.is_a?(String) || r[0] = "=") && r.last.is_a?(Gem::Version) } + end + + def yaml_initialize(tag, vals) # :nodoc: + vals.each do |ivar, val| + instance_variable_set "@#{ivar}", val + end + end + + def init_with(coder) # :nodoc: + yaml_initialize coder.tag, coder.map + end + + def encode_with(coder) # :nodoc: + coder.add "requirements", @requirements + end + + ## + # A requirement is a prerelease if any of the versions inside of it + # are prereleases + + def prerelease? + requirements.any? {|r| r.last.prerelease? } + end + + def pretty_print(q) # :nodoc: + q.group 1, "Gem::Requirement.new(", ")" do + q.pp as_list + end + end + + ## + # True if +version+ satisfies this Requirement. + + def satisfied_by?(version) + raise ArgumentError, "Need a Gem::Version: #{version.inspect}" unless + Gem::Version === version + requirements.all? {|op, rv| OPS.fetch(op).call version, rv } + end + + alias_method :===, :satisfied_by? + alias_method :=~, :satisfied_by? + + ## + # True if the requirement will not always match the latest version. + + def specific? + return true if @requirements.length > 1 # GIGO, > 1, > 2 is silly + + !%w[> >=].include? @requirements.first.first # grab the operator + end + + def to_s # :nodoc: + as_list.join ", " + end + + def ==(other) # :nodoc: + return unless Gem::Requirement === other + + # An == check is always necessary + return false unless _sorted_requirements == other._sorted_requirements + + # An == check is sufficient unless any requirements use ~> + return true unless _tilde_requirements.any? + + # If any requirements use ~> we use the stricter `#eql?` that also checks + # that version precision is the same + _tilde_requirements.eql?(other._tilde_requirements) + end + + protected + + def _sorted_requirements + @_sorted_requirements ||= requirements.sort_by(&:to_s) + end + + def _tilde_requirements + @_tilde_requirements ||= _sorted_requirements.select {|r| r.first == "~>" } + end + + def initialize_copy(other) # :nodoc: + @requirements = other.requirements.dup + super + end +end + +class Gem::Version + # This is needed for compatibility with older yaml + # gemspecs. + + Requirement = Gem::Requirement # :nodoc: +end diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb new file mode 100644 index 0000000000..788206c056 --- /dev/null +++ b/lib/rubygems/resolver.rb @@ -0,0 +1,565 @@ +# frozen_string_literal: true + +require_relative "dependency" +require_relative "exceptions" + +## +# Given a set of Gem::Dependency objects as +needed+ and a way to query the +# set of available specs via +set+, calculates a set of ActivationRequest +# objects which indicate all the specs that should be activated to meet the +# all the requirements. + +class Gem::Resolver + require_relative "vendored_pub_grub" + + ## + # If the DEBUG_RESOLVER environment variable is set then debugging mode is + # enabled for the resolver. This will display information about the state + # of the resolver while a set of dependencies is being resolved. + + DEBUG_RESOLVER = !ENV["DEBUG_RESOLVER"].nil? + + ## + # Set to true if all development dependencies should be considered. + + attr_accessor :development + + ## + # Set to true if immediate development dependencies should be considered. + + attr_accessor :development_shallow + + ## + # When true, no dependencies are looked up for requested gems. + + attr_accessor :ignore_dependencies + + ## + # Hash of gems to skip resolution. Keyed by gem name, with arrays of + # gem specifications as values. + + attr_accessor :skip_gems + + ## + # + + attr_accessor :soft_missing + + ## + # Combines +sets+ into a ComposedSet that allows specification lookup in a + # uniform manner. If one of the +sets+ is itself a ComposedSet its sets are + # flattened into the result ComposedSet. + + def self.compose_sets(*sets) + sets.compact! + + sets = sets.flat_map do |set| + case set + when Gem::Resolver::BestSet then + set + when Gem::Resolver::ComposedSet then + set.sets + else + set + end + end + + case sets.length + when 0 then + raise ArgumentError, "one set in the composition must be non-nil" + when 1 then + sets.first + else + Gem::Resolver::ComposedSet.new(*sets) + end + end + + ## + # Creates a Resolver that queries only against the already installed gems + # for the +needed+ dependencies. + + def self.for_current_gems(needed) + new needed, Gem::Resolver::CurrentSet.new + end + + ## + # Create Resolver object which will resolve the tree starting + # with +needed+ Dependency objects. + # + # +set+ is an object that provides where to look for specifications to + # satisfy the Dependencies. This defaults to IndexSet, which will query + # rubygems.org. + + def initialize(needed, set = nil) + @set = set || Gem::Resolver::IndexSet.new + @needed = needed + + @development = false + @development_shallow = false + @ignore_dependencies = false + @skip_gems = {} + @soft_missing = false + + @root_package = RootPackage.new + @root_version = Gem::PubGrub::Package.root_version + + @packages = {} + + @unfiltered_specs = Hash.new {|h, name| h[name] = find_unfiltered_specs_for(name) } + @all_specs = Hash.new {|h, name| h[name] = filter_specs(@unfiltered_specs[name]) } + @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } + @sorted_versions = Hash.new do |h, pkg| + h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : @all_versions[pkg] + end + @cached_dependencies = Hash.new do |h, pkg| + h[pkg] = if Gem::PubGrub::Package.root?(pkg) + { @root_version => root_dependencies } + else + Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } + end + end + @version_to_index = Hash.new {|h, pkg| h[pkg] = @sorted_versions[pkg].each_with_index.to_h } + @versions_for_cache = Hash.new {|h, pkg| h[pkg] = {} } + @spec_for_cache = Hash.new {|h, name| h[name] = build_spec_for_cache(name) } + end + + ## + # Proceed with resolution! Returns an array of ActivationRequest objects. + + def resolve + # Pre-check: raise UnsatisfiableDependencyError for root deps with no + # platform match. We filter by platform ONLY here (not required_ruby_version + # / required_rubygems_version): a foreign-platform gem is genuinely "not + # found", but a gem that exists yet is incompatible with the running Ruby + # should flow through the solver to a DependencyResolutionError that names + # the Ruby requirement. That matches Bundler (which models Ruby as a + # synthetic dependency, so this surfaces as a solve failure) and gives a + # clearer message than the platform-oriented UnsatisfiableDependencyError. + @needed.each do |dep| + next if @soft_missing + dep_request = DependencyRequest.new(dep, nil) + all = @set.find_all(dep_request) + matching = select_local_platforms(all) + + next unless matching.empty? + + exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) + exc.errors = @set.errors + raise exc + end + + solver = Gem::PubGrub::VersionSolver.new( + source: self, + root: @root_package, + strategy: Gem::Resolver::Strategy.new(self), + logger: make_logger + ) + result = solver.solve + + # Convert to Array<ActivationRequest> + needed_by_name = @needed.group_by(&:name) + result.filter_map do |package, version| + next if Gem::PubGrub::Package.root?(package) + spec = spec_for(package.to_s, version) + dep = needed_by_name[package.to_s]&.first || Gem::Dependency.new(package.to_s) + dep_request = DependencyRequest.new(dep, nil) + ActivationRequest.new(spec, dep_request) + end + rescue Gem::PubGrub::SolveFailure => e + extended = extract_extended_explanation(e.incompatibility) + if extended + message = "#{e.explanation}\n\n#{extended}" + raise Gem::DependencyResolutionError, Struct.new(:explanation).new(message) + else + raise Gem::DependencyResolutionError, e + end + end + + # PubGrub source interface methods + + def all_versions_for(package) + versions = @sorted_versions[package].reverse # highest first + name = package.to_s + + if (skip_dep_gems = skip_gems[name]) && !skip_dep_gems.empty? + # Conservative mode: float the already-installed (skip) versions to the + # front so the solver prefers them. This sets *preference* only (it feeds + # the strategy's version-index map); it does not restrict availability, so + # every version stays selectable via versions_for. When an installed + # version is made impossible by a downstream conflict, the solver + # backtracks to a newer version instead of failing. Molinillo instead + # hard-restricted the candidate set to skip versions and raised. + # + # This reaches the same outcome as Bundler (upgrade-over-raise) for the + # common single-blocked-gem case, though the mechanism differs: Bundler + # hard-pins locked gems and selectively unlocks + re-solves on conflict, + # whereas we float as a preference and let PubGrub backtrack in one solve. + # The float can therefore over-upgrade when several installed gems are + # jointly involved in a conflict; that outcome-level divergence is + # accepted (see test_conservative_upgrades_when_installed_blocked). + skip_versions = skip_dep_gems.map(&:version) + preferred, rest = versions.partition {|v| skip_versions.include?(v) } + preferred + rest + else + # Prefer already-installed versions to avoid unnecessary upgrades + installed_versions = @all_specs[name]. + select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) }. + map(&:version) + if installed_versions.any? + preferred, rest = versions.partition {|v| installed_versions.include?(v) } + preferred + rest + else + versions + end + end + end + + def versions_for(package, range = Gem::PubGrub::VersionRange.any) + @versions_for_cache[package][range] ||= begin + candidates = range.select_versions(@sorted_versions[package]) + + if Gem::PubGrub::Package.root?(package) || + (@set.respond_to?(:prerelease) && @set.prerelease) || + range_admits_prerelease?(range) + candidates + elsif @all_versions[package].any? {|v| !v.prerelease? } + candidates.reject(&:prerelease?) + else + # Only prereleases exist for this gem; fall back to them so + # dependencies like `>= 1.0` can still be satisfied. + candidates + end + end + end + + def no_versions_incompatibility_for(_package, unsatisfied_term) + cause = Gem::PubGrub::Incompatibility::NoVersions.new(unsatisfied_term) + + name = unsatisfied_term.package.to_s + constraint = unsatisfied_term.constraint + extended_explanation = build_extended_explanation(name, constraint) + + custom_explanation = if extended_explanation + "#{constraint} could not be found in any repository" + end + + Gem::Resolver::Incompatibility.new( + [unsatisfied_term], + cause: cause, + custom_explanation: custom_explanation, + extended_explanation: extended_explanation + ) + end + + def incompatibilities_for(package, version) + package_deps = @cached_dependencies[package] + sorted_versions = @sorted_versions[package] + package_deps[version].filter_map do |dep_package_name, dep_constraint| + dep_package = dep_constraint.package + + low = high = @version_to_index[package][version] + + # find version low such that all >= low share the same dep + while low > 0 && + package_deps[sorted_versions[low - 1]][dep_package_name] == dep_constraint + low -= 1 + end + low = + if low == 0 + nil + else + sorted_versions[low] + end + + # find version high such that all < high share the same dep + while high < sorted_versions.length && + package_deps[sorted_versions[high]][dep_package_name] == dep_constraint + high += 1 + end + high = + if high == sorted_versions.length + nil + else + sorted_versions[high] + end + + range = Gem::PubGrub::VersionRange.new(min: low, max: high, include_min: !low.nil?) + self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: range) + + # No specs anywhere means an unknown package. Check @unfiltered_specs, not + # the filtered set, so a dep filtered out by platform/Ruby/prerelease falls + # through to NoVersions for proper hints instead. The band-scoped + # self_constraint lets clean sibling versions still resolve via backtracking. + if @unfiltered_specs[dep_package_name].empty? + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + self_term = Gem::PubGrub::Term.new(self_constraint, true) + # PubGrub's default InvalidDependency rendering drops the version + # requirement ("depends on unknown package bar"). Supply a custom + # explanation so the missing dependency's constraint is preserved + # ("depends on bar = 0.5 which could not be found in any repository"), + # matching Molinillo's diagnostics. + return [Gem::PubGrub::Incompatibility.new( + [self_term], + cause: cause, + custom_explanation: "#{self_term.to_s(allow_every: true)} depends on #{dep_constraint} which could not be found in any repository" + )] + end + + # An empty range means the requirement is self-contradictory (e.g. `> 2, < 1`). + if dep_constraint.range.empty? + return [Gem::Resolver::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: Gem::PubGrub::Incompatibility::NoVersions.new(dep_constraint), + custom_explanation: "#{dep_package_name} cannot satisfy contradictory requirements #{dep_constraint.constraint_string}" + )] + end + + Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true), Gem::PubGrub::Term.new(dep_constraint, false)], + cause: :dependency + ) + end + end + + ## + # Returns the gems in +specs+ that match the local platform. + + def select_local_platforms(specs) # :nodoc: + specs.select do |spec| + Gem::Platform.installable? spec + end + end + + private + + def package_for(name) + @packages[name] ||= Gem::PubGrub::Package.new(name) + end + + def root_dependencies + deps = {} + @needed.each do |dep| + constraint = Gem::PubGrub::RubyGems.requirement_to_constraint(package_for(dep.name), dep.requirement) + deps[dep.name] = deps.key?(dep.name) ? deps[dep.name].intersect(constraint) : constraint + end + deps + end + + # Only the min bound is inspected: `~>` synthesises a max like `X.A` + # whose suffix looks prerelease to Gem::Version but is not the user's + # intent, so checking max would mis-admit prereleases for every `~>`. + def range_admits_prerelease?(range) + range.ranges.any? do |r| + next false if r.empty? + r.min&.prerelease? + end + end + + def find_unfiltered_specs_for(name) + dep = Gem::Dependency.new(name, ">= 0.a") + dep_request = DependencyRequest.new(dep, nil) + @set.find_all(dep_request) + end + + def filter_specs(specs) + filtered = select_local_platforms(specs) + + unless @soft_missing + filtered = filtered.select do |s| + s.required_ruby_version.satisfied_by?(Gem.ruby_version) && + s.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + rescue StandardError + true + end + end + + filtered + end + + def spec_for(name, version) + @spec_for_cache[name][version] + end + + def build_spec_for_cache(name) + # Rank sources by the order they were first supplied so that, when multiple + # sources offer the same version and platform, the earlier source wins. + source_rank = {} + @all_specs[name].each do |s| + source_rank[s.source] ||= source_rank.size + end + + @all_specs[name].group_by(&:version).transform_values do |candidates| + next candidates.first if candidates.length == 1 + + # Prefer already-installed specs to avoid unnecessary downloads + installed = candidates.select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) } + next installed.first if installed.length == 1 + candidates = installed if installed.any? + + # Among remaining candidates, prefer the most specific platform, then the + # earlier-supplied source. + candidates.min_by do |s| + [Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), + source_rank[s.source]] + end + end + end + + def compute_dependencies(package, version) + spec = spec_for(package.to_s, version) + return {} unless spec + return {} if @ignore_dependencies + + spec.fetch_development_dependencies if @development && spec.respond_to?(:fetch_development_dependencies) + + deps = {} + root_names = @needed.map(&:name) + + spec.dependencies.each do |d| + next if d.name == package.to_s + next if d.type == :development && !@development + next if d.type == :development && @development_shallow && !root_names.include?(package.to_s) + + dep_package = package_for(d.name) + + # In force mode, skip deps that can't be satisfied - either no + # specs at all, or no specs matching the version requirement. + if @soft_missing + dep_specs = @all_specs[d.name] + matching = dep_specs.select {|s| d.requirement.satisfied_by?(s.version) } + next if matching.empty? + end + + deps[d.name] = Gem::PubGrub::RubyGems.requirement_to_constraint(dep_package, d.requirement) + end + + deps + end + + def build_extended_explanation(name, constraint) + unfiltered = @unfiltered_specs[name] + return if unfiltered.empty? + + filtered = @all_specs[name] + pkg = package_for(name) + + # A prerelease hint applies when the source would strip prereleases for + # this constraint (global prerelease flag off and the constraint's range + # doesn't itself reach into prerelease territory) AND a prerelease of + # the gem exists somewhere. + prerelease_gated = !(@set.respond_to?(:prerelease) && @set.prerelease) && + !range_admits_prerelease?(constraint.range) + has_prerelease_candidate = prerelease_gated && + @all_versions[pkg].any?(&:prerelease?) + + return if filtered.length == unfiltered.length && !has_prerelease_candidate + + hints = [] + + # Check for specs that exist for other platforms + platform_specs = unfiltered.select do |s| + !Gem::Platform.installable?(s) && constraint.range.include?(s.version) + end + if platform_specs.any? + label = "#{name} (#{constraint.constraint_string})" + hints << "The source contains the following gems matching '#{label}':" + platform_specs.each do |s| + actual = s.respond_to?(:spec) ? s.spec : s + hints << " * #{actual.full_name}" + end + end + + # Check for specs filtered by Ruby version + installable = select_local_platforms(unfiltered) + ruby_specs = installable.select do |s| + actual = s.respond_to?(:spec) ? s.spec : s + constraint.range.include?(s.version) && + !actual.required_ruby_version.satisfied_by?(Gem.ruby_version) + rescue StandardError + false + end + if ruby_specs.any? + versions = ruby_specs.map(&:version).uniq.sort.reverse.first(3) + sample = ruby_specs.find {|s| s.version == versions.first } + actual = sample.respond_to?(:spec) ? sample.spec : sample + ruby_req = actual.required_ruby_version + hints << "#{name} #{versions.join(", ")} requires Ruby #{ruby_req} (you have #{Gem.ruby_version})" + end + + # Check for specs filtered by prerelease status + if prerelease_gated + prerelease_versions = @all_versions[pkg].select(&:prerelease?) + if prerelease_versions.any? + versions = prerelease_versions.sort.reverse.first(3) # limit to avoid cluttering error output + hints << "#{name} #{versions.join(", ")} are pre-release versions. Use --prerelease to allow pre-release gems." + end + end + + hints.empty? ? nil : hints.join("\n") + end + + def extract_extended_explanation(incompatibility) + while incompatibility.cause.is_a?(Gem::PubGrub::Incompatibility::ConflictCause) + cause = incompatibility.cause + + [cause.conflict, cause.other].each do |incompat| + if incompat.cause.is_a?(Gem::PubGrub::Incompatibility::NoVersions) && + incompat.respond_to?(:extended_explanation) && + incompat.extended_explanation + return incompat.extended_explanation + end + end + + incompatibility = cause.conflict + end + + nil + end + + def make_logger + DEBUG_RESOLVER ? Gem::PubGrub::StderrLogger.new : Gem::PubGrub::NullLogger.new + end + + # Custom root package so error messages say "your request depends on..." + # instead of PubGrub's default "root depends on...". + class RootPackage < Gem::PubGrub::Package + def initialize + super(:root) + end + + def root? + true + end + + def to_s + "your request" + end + end +end + +require_relative "resolver/activation_request" +require_relative "resolver/dependency_request" +require_relative "resolver/incompatibility" +require_relative "resolver/strategy" +require_relative "resolver/requirement_list" +require_relative "resolver/set" +require_relative "resolver/api_set" +require_relative "resolver/composed_set" +require_relative "resolver/best_set" +require_relative "resolver/current_set" +require_relative "resolver/git_set" +require_relative "resolver/index_set" +require_relative "resolver/installer_set" +require_relative "resolver/lock_set" +require_relative "resolver/vendor_set" +require_relative "resolver/source_set" + +require_relative "resolver/specification" +require_relative "resolver/spec_specification" +require_relative "resolver/api_specification" +require_relative "resolver/git_specification" +require_relative "resolver/index_specification" +require_relative "resolver/installed_specification" +require_relative "resolver/local_specification" +require_relative "resolver/lock_specification" +require_relative "resolver/vendor_specification" diff --git a/lib/rubygems/resolver/activation_request.rb b/lib/rubygems/resolver/activation_request.rb new file mode 100644 index 0000000000..5c722001b1 --- /dev/null +++ b/lib/rubygems/resolver/activation_request.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +## +# Specifies a Specification object that should be activated. Also contains a +# dependency that was used to introduce this activation. + +class Gem::Resolver::ActivationRequest + ## + # The parent request for this activation request. + + attr_reader :request + + ## + # The specification to be activated. + + attr_reader :spec + + ## + # Creates a new ActivationRequest that will activate +spec+. The parent + # +request+ is used to provide diagnostics in case of conflicts. + + def initialize(spec, request) + @spec = spec + @request = request + end + + def ==(other) # :nodoc: + case other + when Gem::Specification + @spec == other + when Gem::Resolver::ActivationRequest + @spec == other.spec + else + false + end + end + + def eql?(other) + self == other + end + + def hash + @spec.hash + end + + ## + # Is this activation request for a development dependency? + + def development? + @request.development? + end + + ## + # Downloads a gem at +path+ and returns the file path. + + def download(path) + Gem.ensure_gem_subdirectories path + + if @spec.respond_to? :sources + exception = nil + path = @spec.sources.find do |source| + source.download full_spec, path + rescue exception + end + return path if path + raise exception if exception + + elsif @spec.respond_to? :source + source = @spec.source + source.download full_spec, path + + else + source = Gem.sources.first + source.download full_spec, path + end + end + + ## + # The full name of the specification to be activated. + + def full_name + name_tuple.full_name + end + + alias_method :to_s, :full_name + + ## + # The Gem::Specification for this activation request. + + def full_spec + Gem::Specification === @spec ? @spec : @spec.spec + end + + def inspect # :nodoc: + format("#<%s for %p from %s>", self.class, @spec, @request) + end + + ## + # True if the requested gem has already been installed. + + def installed? + case @spec + when Gem::Resolver::VendorSpecification then + true + else + this_spec = full_spec + + Gem::Specification.any? do |s| + s == this_spec && s.base_dir == this_spec.base_dir + end + end + end + + ## + # The name of this activation request's specification + + def name + @spec.name + end + + ## + # Return the ActivationRequest that contained the dependency + # that we were activated for. + + def parent + @request.requester + end + + def pretty_print(q) # :nodoc: + q.group 2, "[Activation request", "]" do + q.breakable + q.pp @spec + + q.breakable + q.text " for " + q.pp @request + end + end + + ## + # The version of this activation request's specification + + def version + @spec.version + end + + ## + # The platform of this activation request's specification + + def platform + @spec.platform + end + + private + + def name_tuple + @name_tuple ||= Gem::NameTuple.new(name, version, platform) + end +end diff --git a/lib/rubygems/resolver/api_set.rb b/lib/rubygems/resolver/api_set.rb new file mode 100644 index 0000000000..3f443519d8 --- /dev/null +++ b/lib/rubygems/resolver/api_set.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +## +# The global rubygems pool, available via the Compact Index API. +# Returns instances of APISpecification. + +class Gem::Resolver::APISet < Gem::Resolver::Set + autoload :GemParser, File.expand_path("api_set/gem_parser", __dir__) + + ## + # The URI for the Compact Index API this APISet uses. + + attr_reader :dep_uri # :nodoc: + + ## + # The Gem::Source that gems are fetched from + + attr_reader :source + + ## + # The corresponding place to fetch gems. + + attr_reader :uri + + ## + # Creates a new APISet that will retrieve gems from +uri+ using the Compact + # Index API URL +dep_uri+ which is described at + # https://guides.rubygems.org/rubygems-org-compact-index-api + + def initialize(dep_uri = "https://index.rubygems.org/info/") + super() + + dep_uri = Gem::URI dep_uri unless Gem::URI === dep_uri + + @dep_uri = dep_uri + @uri = dep_uri + ".." + + @data = Hash.new {|h,k| h[k] = [] } + @source = Gem::Source.new @uri + + @to_fetch = [] + end + + ## + # Return an array of APISpecification objects matching + # DependencyRequest +req+. + + def find_all(req) + res = [] + + return res unless @remote + + if @to_fetch.include?(req.name) + prefetch_now + end + + versions(req.name).each do |ver| + if req.dependency.match? req.name, ver[:number], @prerelease + res << Gem::Resolver::APISpecification.new(self, ver) + end + end + + res + end + + ## + # A hint run by the resolver to allow the Set to fetch + # data for DependencyRequests +reqs+. + + def prefetch(reqs) + return unless @remote + names = reqs.map {|r| r.dependency.name } + needed = names - @data.keys - @to_fetch + + @to_fetch += needed + end + + def prefetch_now # :nodoc: + needed = @to_fetch + @to_fetch = [] + + needed.sort.each do |name| + versions(name) + end + end + + def pretty_print(q) # :nodoc: + q.group 2, "[APISet", "]" do + q.breakable + q.text "URI: #{@dep_uri}" + + q.breakable + q.text "gem names:" + q.pp @data.keys + end + end + + ## + # Return data for all versions of the gem +name+. + + def versions(name) # :nodoc: + if @data.key?(name) + return @data[name] + end + + uri = @dep_uri + name + + begin + str = Gem::RemoteFetcher.fetcher.fetch_path uri + rescue Gem::RemoteFetcher::FetchError + @data[name] = [] + else + lines(str).each do |ver| + number, platform, dependencies, requirements = parse_gem(ver) + + platform ||= "ruby" + dependencies = dependencies.map {|dep_name, reqs| [dep_name, reqs.join(", ")] } + requirements = requirements.map {|req_name, reqs| [req_name.to_sym, reqs] }.to_h + + @data[name] << { name: name, number: number, platform: platform, dependencies: dependencies, requirements: requirements } + end + end + + @data[name] + end + + private + + def lines(str) + lines = str.split("\n") + header = lines.index("---") + header ? lines[header + 1..-1] : lines + end + + def parse_gem(string) + @gem_parser ||= GemParser.new + @gem_parser.parse(string) + end +end diff --git a/lib/rubygems/resolver/api_set/gem_parser.rb b/lib/rubygems/resolver/api_set/gem_parser.rb new file mode 100644 index 0000000000..4d827f4980 --- /dev/null +++ b/lib/rubygems/resolver/api_set/gem_parser.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Gem::Resolver::APISet::GemParser + def parse(line) + version_and_platform, rest = line.split(" ", 2) + version, platform = version_and_platform.split("-", 2) + dependencies, requirements = rest.split("|", 2).map! {|s| s.split(",") } if rest + dependencies = dependencies ? dependencies.map! {|d| parse_dependency(d) } : [] + requirements = requirements ? requirements.map! {|d| parse_dependency(d) } : [] + [version, platform, dependencies, requirements] + end + + private + + def parse_dependency(string) + dependency = string.split(":", 2) + dependency[-1] = dependency[-1].split("&") if dependency.size > 1 + dependency[0] = -dependency[0] + dependency + end +end diff --git a/lib/rubygems/resolver/api_specification.rb b/lib/rubygems/resolver/api_specification.rb new file mode 100644 index 0000000000..ccfd6fe084 --- /dev/null +++ b/lib/rubygems/resolver/api_specification.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +## +# Represents a specification retrieved via the Compact Index API. +# +# This is used to avoid loading the full Specification object when all we need +# is the name, version, and dependencies. + +class Gem::Resolver::APISpecification < Gem::Resolver::Specification + ## + # We assume that all instances of this class are immutable; + # so avoid duplicated generation for performance. + @@cache = {} + def self.new(set, api_data) + cache_key = [set, api_data] + cache = @@cache[cache_key] + return cache if cache + @@cache[cache_key] = super + end + + ## + # Creates an APISpecification for the given +set+ from the Compact Index API + # +api_data+. + # + # See https://guides.rubygems.org/rubygems-org-compact-index-api for the + # format of the +api_data+. + + def initialize(set, api_data) + super() + + @set = set + @name = api_data[:name] + @version = Gem::Version.new(api_data[:number]).freeze + @platform = Gem::Platform.new(api_data[:platform]).freeze + @original_platform = api_data[:platform].freeze + @dependencies = api_data[:dependencies].map do |name, ver| + Gem::Dependency.new(name, ver.split(/\s*,\s*/)).freeze + end.freeze + @required_ruby_version = Gem::Requirement.new(api_data.dig(:requirements, :ruby)).freeze + @required_rubygems_version = Gem::Requirement.new(api_data.dig(:requirements, :rubygems)).freeze + end + + def ==(other) # :nodoc: + self.class === other && + @set == other.set && + @name == other.name && + @version == other.version && + @platform == other.platform + end + + def hash + @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash + end + + def fetch_development_dependencies # :nodoc: + spec = source.fetch_spec Gem::NameTuple.new @name, @version, @platform + + @dependencies = spec.dependencies + end + + def installable_platform? # :nodoc: + Gem::Platform.match_gem? @platform, @name + end + + def pretty_print(q) # :nodoc: + q.group 2, "[APISpecification", "]" do + q.breakable + q.text "name: #{name}" + + q.breakable + q.text "version: #{version}" + + q.breakable + q.text "platform: #{platform}" + + q.breakable + q.text "dependencies:" + q.breakable + q.pp @dependencies + + q.breakable + q.text "set uri: #{@set.dep_uri}" + end + end + + ## + # Fetches a Gem::Specification for this APISpecification. + + def spec # :nodoc: + @spec ||= + begin + tuple = Gem::NameTuple.new @name, @version, @platform + source.fetch_spec tuple + rescue Gem::RemoteFetcher::FetchError + raise if @original_platform == @platform + + tuple = Gem::NameTuple.new @name, @version, @original_platform + source.fetch_spec tuple + end + end + + def source # :nodoc: + @set.source + end +end diff --git a/lib/rubygems/resolver/best_set.rb b/lib/rubygems/resolver/best_set.rb new file mode 100644 index 0000000000..e647a2c11b --- /dev/null +++ b/lib/rubygems/resolver/best_set.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +## +# The BestSet chooses the best available method to query a remote index. +# +# It combines IndexSet and APISet + +class Gem::Resolver::BestSet < Gem::Resolver::ComposedSet + ## + # Creates a BestSet for the given +sources+ or Gem::sources if none are + # specified. +sources+ must be a Gem::SourceList. + + def initialize(sources = Gem.sources) + super() + + @sources = sources + end + + ## + # Picks which sets to use for the configured sources. + + def pick_sets # :nodoc: + @sources.each_source do |source| + @sets << source.dependency_resolver_set(@prerelease) + end + end + + def find_all(req) # :nodoc: + pick_sets if @remote && @sets.empty? + + super + end + + def prefetch(reqs) # :nodoc: + pick_sets if @remote && @sets.empty? + + super + end + + def pretty_print(q) # :nodoc: + q.group 2, "[BestSet", "]" do + q.breakable + q.text "sets:" + + q.breakable + q.pp @sets + end + end +end diff --git a/lib/rubygems/resolver/composed_set.rb b/lib/rubygems/resolver/composed_set.rb new file mode 100644 index 0000000000..e67dd41754 --- /dev/null +++ b/lib/rubygems/resolver/composed_set.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +## +# A ComposedSet allows multiple sets to be queried like a single set. +# +# To create a composed set with any number of sets use: +# +# Gem::Resolver.compose_sets set1, set2 +# +# This method will eliminate nesting of composed sets. + +class Gem::Resolver::ComposedSet < Gem::Resolver::Set + attr_reader :sets # :nodoc: + + ## + # Creates a new ComposedSet containing +sets+. Use + # Gem::Resolver::compose_sets instead. + + def initialize(*sets) + super() + + @sets = sets + end + + ## + # When +allow_prerelease+ is set to +true+ prereleases gems are allowed to + # match dependencies. + + def prerelease=(allow_prerelease) + super + + sets.each do |set| + set.prerelease = allow_prerelease + end + end + + ## + # Sets the remote network access for all composed sets. + + def remote=(remote) + super + + @sets.each {|set| set.remote = remote } + end + + def errors + @errors + @sets.flat_map(&:errors) + end + + ## + # Finds all specs matching +req+ in all sets. + + def find_all(req) + @sets.flat_map do |s| + s.find_all req + end + end + + ## + # Prefetches +reqs+ in all sets. + + def prefetch(reqs) + @sets.each {|s| s.prefetch(reqs) } + end +end diff --git a/lib/rubygems/resolver/current_set.rb b/lib/rubygems/resolver/current_set.rb new file mode 100644 index 0000000000..370e445089 --- /dev/null +++ b/lib/rubygems/resolver/current_set.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +## +# A set which represents the installed gems. Respects +# all the normal settings that control where to look +# for installed gems. + +class Gem::Resolver::CurrentSet < Gem::Resolver::Set + def find_all(req) + req.dependency.matching_specs + end +end diff --git a/lib/rubygems/resolver/dependency_request.rb b/lib/rubygems/resolver/dependency_request.rb new file mode 100644 index 0000000000..60b338277f --- /dev/null +++ b/lib/rubygems/resolver/dependency_request.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +## +# Used Internally. Wraps a Dependency object to also track which spec +# contained the Dependency. + +class Gem::Resolver::DependencyRequest + ## + # The wrapped Gem::Dependency + + attr_reader :dependency + + ## + # The request for this dependency. + + attr_reader :requester + + ## + # Creates a new DependencyRequest for +dependency+ from +requester+. + # +requester may be nil if the request came from a user. + + def initialize(dependency, requester) + @dependency = dependency + @requester = requester + end + + def ==(other) # :nodoc: + case other + when Gem::Dependency + @dependency == other + when Gem::Resolver::DependencyRequest + @dependency == other.dependency + else + false + end + end + + ## + # Is this dependency a development dependency? + + def development? + @dependency.type == :development + end + + ## + # Does this dependency request match +spec+? + # + # NOTE: #match? only matches prerelease versions when #dependency is a + # prerelease dependency. + + def match?(spec, allow_prerelease = false) + @dependency.match? spec, nil, allow_prerelease + end + + ## + # Does this dependency request match +spec+? + # + # NOTE: #matches_spec? matches prerelease versions. See also #match? + + def matches_spec?(spec) + @dependency.matches_spec? spec + end + + ## + # The name of the gem this dependency request is requesting. + + def name + @dependency.name + end + + def type + @dependency.type + end + + ## + # Indicate that the request is for a gem explicitly requested by the user + + def explicit? + @requester.nil? + end + + ## + # Indicate that the request is for a gem requested as a dependency of + # another gem + + def implicit? + !explicit? + end + + ## + # Return a String indicating who caused this request to be added (only + # valid for implicit requests) + + def request_context + @requester ? @requester.request : "(unknown)" + end + + def pretty_print(q) # :nodoc: + q.group 2, "[Dependency request ", "]" do + q.breakable + q.text @dependency.to_s + + q.breakable + q.text " requested by " + q.pp @requester + end + end + + ## + # The version requirement for this dependency request + + def requirement + @dependency.requirement + end + + def to_s # :nodoc: + @dependency.to_s + end +end diff --git a/lib/rubygems/resolver/git_set.rb b/lib/rubygems/resolver/git_set.rb new file mode 100644 index 0000000000..2912378fe7 --- /dev/null +++ b/lib/rubygems/resolver/git_set.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +## +# A GitSet represents gems that are sourced from git repositories. +# +# This is used for gem dependency file support. +# +# Example: +# +# set = Gem::Resolver::GitSet.new +# set.add_git_gem 'rake', 'git://example/rake.git', tag: 'rake-10.1.0' + +class Gem::Resolver::GitSet < Gem::Resolver::Set + ## + # The root directory for git gems in this set. This is usually Gem.dir, the + # installation directory for regular gems. + + attr_accessor :root_dir + + ## + # Contains repositories needing submodules + + attr_reader :need_submodules # :nodoc: + + ## + # A Hash containing git gem names for keys and a Hash of repository and + # git commit reference as values. + + attr_reader :repositories # :nodoc: + + ## + # A hash of gem names to Gem::Resolver::GitSpecifications + + attr_reader :specs # :nodoc: + + def initialize # :nodoc: + super() + + @need_submodules = {} + @repositories = {} + @root_dir = Gem.dir + @specs = {} + end + + def add_git_gem(name, repository, reference, submodules) # :nodoc: + @repositories[name] = [repository, reference] + @need_submodules[repository] = submodules + end + + ## + # Adds and returns a GitSpecification with the given +name+ and +version+ + # which came from a +repository+ at the given +reference+. If +submodules+ + # is true they are checked out along with the repository. + # + # This fills in the prefetch information as enough information about the gem + # is present in the arguments. + + def add_git_spec(name, version, repository, reference, submodules) # :nodoc: + add_git_gem name, repository, reference, submodules + + source = Gem::Source::Git.new name, repository, reference + source.root_dir = @root_dir + + spec = Gem::Specification.new do |s| + s.name = name + s.version = version + end + + git_spec = Gem::Resolver::GitSpecification.new self, spec, source + + @specs[spec.name] = git_spec + + git_spec + end + + ## + # Finds all git gems matching +req+ + + def find_all(req) + prefetch nil + + specs.values.select do |spec| + req.match? spec + end + end + + ## + # Prefetches specifications from the git repositories in this set. + + def prefetch(reqs) + return unless @specs.empty? + + @repositories.each do |name, (repository, reference)| + source = Gem::Source::Git.new name, repository, reference + source.root_dir = @root_dir + source.remote = @remote + + source.specs.each do |spec| + git_spec = Gem::Resolver::GitSpecification.new self, spec, source + + @specs[spec.name] = git_spec + end + end + end + + def pretty_print(q) # :nodoc: + q.group 2, "[GitSet", "]" do + next if @repositories.empty? + q.breakable + + repos = @repositories.map do |name, (repository, reference)| + "#{name}: #{repository}@#{reference}" + end + + q.seplist repos do |repo| + q.text repo + end + end + end +end diff --git a/lib/rubygems/resolver/git_specification.rb b/lib/rubygems/resolver/git_specification.rb new file mode 100644 index 0000000000..e587c17d2a --- /dev/null +++ b/lib/rubygems/resolver/git_specification.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +## +# A GitSpecification represents a gem that is sourced from a git repository +# and is being loaded through a gem dependencies file through the +git:+ +# option. + +class Gem::Resolver::GitSpecification < Gem::Resolver::SpecSpecification + def ==(other) # :nodoc: + self.class === other && + @set == other.set && + @spec == other.spec && + @source == other.source + end + + def add_dependency(dependency) # :nodoc: + spec.dependencies << dependency + end + + ## + # Installing a git gem only involves building the extensions and generating + # the executables. + + def install(options = {}) + require_relative "../installer" + + installer = Gem::Installer.for_spec spec, options + + yield installer if block_given? + + installer.run_pre_install_hooks + installer.build_extensions + installer.run_post_build_hooks + installer.generate_bin + installer.run_post_install_hooks + end + + def pretty_print(q) # :nodoc: + q.group 2, "[GitSpecification", "]" do + q.breakable + q.text "name: #{name}" + + q.breakable + q.text "version: #{version}" + + q.breakable + q.text "dependencies:" + q.breakable + q.pp dependencies + + q.breakable + q.text "source:" + q.breakable + q.pp @source + end + end +end diff --git a/lib/rubygems/resolver/incompatibility.rb b/lib/rubygems/resolver/incompatibility.rb new file mode 100644 index 0000000000..57a60affb4 --- /dev/null +++ b/lib/rubygems/resolver/incompatibility.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Gem::Resolver::Incompatibility < Gem::PubGrub::Incompatibility + attr_reader :extended_explanation + + def initialize(terms, cause:, custom_explanation: nil, extended_explanation: nil) + @extended_explanation = extended_explanation + super(terms, cause: cause, custom_explanation: custom_explanation) + end +end diff --git a/lib/rubygems/resolver/index_set.rb b/lib/rubygems/resolver/index_set.rb new file mode 100644 index 0000000000..cddaf8773f --- /dev/null +++ b/lib/rubygems/resolver/index_set.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +## +# The global rubygems pool represented via the traditional +# source index. + +class Gem::Resolver::IndexSet < Gem::Resolver::Set + def initialize(source = nil) # :nodoc: + super() + + @f = + if source + sources = Gem::SourceList.from [source] + + Gem::SpecFetcher.new sources + else + Gem::SpecFetcher.fetcher + end + + @all = Hash.new {|h,k| h[k] = [] } + + list, errors = @f.available_specs :complete + + @errors.concat errors + + list.each do |uri, specs| + specs.each do |n| + @all[n.name] << [uri, n] + end + end + + @specs = {} + end + + ## + # Return an array of IndexSpecification objects matching + # DependencyRequest +req+. + + def find_all(req) + res = [] + + return res unless @remote + + name = req.dependency.name + + @all[name].each do |uri, n| + next unless req.match? n, @prerelease + res << Gem::Resolver::IndexSpecification.new( + self, n.name, n.version, uri, n.platform + ) + end + + res + end + + def pretty_print(q) # :nodoc: + q.group 2, "[IndexSet", "]" do + q.breakable + q.text "sources:" + q.breakable + q.pp @f.sources + + q.breakable + q.text "specs:" + + q.breakable + + names = @all.values.flat_map do |tuples| + tuples.map do |_, tuple| + tuple.full_name + end + end + + q.seplist names do |name| + q.text name + end + end + end +end diff --git a/lib/rubygems/resolver/index_specification.rb b/lib/rubygems/resolver/index_specification.rb new file mode 100644 index 0000000000..7b95608071 --- /dev/null +++ b/lib/rubygems/resolver/index_specification.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +## +# Represents a possible Specification object returned from IndexSet. Used to +# delay needed to download full Specification objects when only the +name+ +# and +version+ are needed. + +class Gem::Resolver::IndexSpecification < Gem::Resolver::Specification + ## + # An IndexSpecification is created from the index format described in `gem + # help generate_index`. + # + # The +set+ contains other specifications for this (URL) +source+. + # + # The +name+, +version+ and +platform+ are the name, version and platform of + # the gem. + + def initialize(set, name, version, source, platform) + super() + + @set = set + @name = name + @version = version + @source = source + @platform = Gem::Platform.new(platform.to_s) + @original_platform = platform.to_s + + @spec = nil + end + + ## + # The dependencies of the gem for this specification + + def dependencies + spec.dependencies + end + + ## + # The required_ruby_version constraint for this specification + # + # A fallback is included because when generated, some marshalled specs have it + # set to +nil+. + + def required_ruby_version + spec.required_ruby_version || Gem::Requirement.default + end + + ## + # The required_rubygems_version constraint for this specification + # + # A fallback is included because the original version of the specification + # API didn't include that field, so some marshalled specs in the index have it + # set to +nil+. + + def required_rubygems_version + spec.required_rubygems_version || Gem::Requirement.default + end + + def ==(other) + self.class === other && + @name == other.name && + @version == other.version && + @platform == other.platform + end + + def hash + @name.hash ^ @version.hash ^ @platform.hash + end + + def inspect # :nodoc: + format("#<%s %s source %s>", self.class, full_name, @source) + end + + def pretty_print(q) # :nodoc: + q.group 2, "[Index specification", "]" do + q.breakable + q.text full_name + + unless @platform == Gem::Platform::RUBY + q.breakable + q.text @platform.to_s + end + + q.breakable + q.text "source " + q.pp @source + end + end + + ## + # Fetches a Gem::Specification for this IndexSpecification from the #source. + + def spec # :nodoc: + @spec ||= + begin + tuple = Gem::NameTuple.new @name, @version, @original_platform + + @source.fetch_spec tuple + end + end +end diff --git a/lib/rubygems/resolver/installed_specification.rb b/lib/rubygems/resolver/installed_specification.rb new file mode 100644 index 0000000000..8280ae4672 --- /dev/null +++ b/lib/rubygems/resolver/installed_specification.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +## +# An InstalledSpecification represents a gem that is already installed +# locally. + +class Gem::Resolver::InstalledSpecification < Gem::Resolver::SpecSpecification + def ==(other) # :nodoc: + self.class === other && + @set == other.set && + @spec == other.spec + end + + ## + # This is a null install as this specification is already installed. + # +options+ are ignored. + + def install(options = {}) + yield nil + end + + ## + # Returns +true+ if this gem is installable for the current platform. + + def installable_platform? + # BACKCOMPAT If the file is coming out of a specified file, then we + # ignore the platform. This code can be removed in RG 3.0. + return true if @source.is_a? Gem::Source::SpecificFile + + super + end + + def pretty_print(q) # :nodoc: + q.group 2, "[InstalledSpecification", "]" do + q.breakable + q.text "name: #{name}" + + q.breakable + q.text "version: #{version}" + + q.breakable + q.text "platform: #{platform}" + + q.breakable + q.text "dependencies:" + q.breakable + q.pp spec.dependencies + end + end + + ## + # The source for this specification + + def source + @source ||= Gem::Source::Installed.new + end +end diff --git a/lib/rubygems/resolver/installer_set.rb b/lib/rubygems/resolver/installer_set.rb new file mode 100644 index 0000000000..42ce0890e2 --- /dev/null +++ b/lib/rubygems/resolver/installer_set.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +## +# A set of gems for installation sourced from remote sources and local .gem +# files + +class Gem::Resolver::InstallerSet < Gem::Resolver::Set + ## + # List of Gem::Specification objects that must always be installed. + + attr_reader :always_install # :nodoc: + + ## + # Only install gems in the always_install list + + attr_accessor :ignore_dependencies # :nodoc: + + ## + # Do not look in the installed set when finding specifications. This is + # used by the --install-dir option to `gem install` + + attr_accessor :ignore_installed # :nodoc: + + ## + # The remote_set looks up remote gems for installation. + + attr_reader :remote_set # :nodoc: + + ## + # Ignore ruby & rubygems specification constraints. + # + + attr_accessor :force # :nodoc: + + ## + # Creates a new InstallerSet that will look for gems in +domain+. + + def initialize(domain) + super() + + @domain = domain + + @f = Gem::SpecFetcher.fetcher + + @always_install = [] + @ignore_dependencies = false + @ignore_installed = false + @local = {} + @local_source = Gem::Source::Local.new + @remote_set = Gem::Resolver::BestSet.new + @force = false + @specs = {} + end + + ## + # Looks up the latest specification for +dependency+ and adds it to the + # always_install list. + + def add_always_install(dependency) + request = Gem::Resolver::DependencyRequest.new dependency, nil + + found = find_all request + + found.delete_if do |s| + s.version.prerelease? && !s.local? + end unless dependency.prerelease? + + found = found.select do |s| + Gem::Source::SpecificFile === s.source || + Gem::Platform.match_spec?(s) + end + + found = found.sort_by do |s| + [s.version, Gem::Platform.sort_priority(s.platform)] + end + + newest = found.last + + unless newest + exc = Gem::UnsatisfiableDependencyError.new request + exc.errors = errors + + raise exc + end + + unless @force + found_matching_metadata = found.reverse.find do |spec| + metadata_satisfied?(spec) + end + + if found_matching_metadata.nil? + ensure_required_ruby_version_met(newest.spec) + ensure_required_rubygems_version_met(newest.spec) + else + newest = found_matching_metadata + end + end + + @always_install << newest.spec + end + + ## + # Adds a local gem requested using +dep_name+ with the given +spec+ that can + # be loaded and installed using the +source+. + + def add_local(dep_name, spec, source) + @local[dep_name] = [spec, source] + end + + ## + # Should local gems should be considered? + + def consider_local? # :nodoc: + @domain == :both || @domain == :local + end + + ## + # Should remote gems should be considered? + + def consider_remote? # :nodoc: + @domain == :both || @domain == :remote + end + + ## + # Errors encountered while resolving gems + + def errors + @errors + @remote_set.errors + end + + ## + # Returns an array of IndexSpecification objects matching DependencyRequest + # +req+. + + def find_all(req) + res = [] + + dep = req.dependency + + return res if @ignore_dependencies && + @always_install.none? {|spec| dep.match? spec } + + name = dep.name + + dep.matching_specs.each do |gemspec| + next if @always_install.any? {|spec| spec.name == gemspec.name } + + res << Gem::Resolver::InstalledSpecification.new(self, gemspec) + end unless @ignore_installed + + matching_local = [] + + if consider_local? + matching_local = @local.values.select do |spec, _| + req.match? spec + end.map do |spec, source| + Gem::Resolver::LocalSpecification.new self, spec, source + end + + res.concat matching_local + + begin + @local_source.find_all_gems(name, dep.requirement).each do |local_spec| + res << Gem::Resolver::IndexSpecification.new( + self, local_spec.name, local_spec.version, + @local_source, local_spec.platform + ) + end + rescue Gem::Package::FormatError + # ignore + end + end + + res.concat @remote_set.find_all req if consider_remote? && matching_local.empty? + + res + end + + def prefetch(reqs) + @remote_set.prefetch(reqs) if consider_remote? + end + + def prerelease=(allow_prerelease) + super + + @remote_set.prerelease = allow_prerelease + end + + def inspect # :nodoc: + always_install = @always_install.map(&:full_name) + + format("#<%s domain: %s specs: %p always install: %p>", self.class, @domain, @specs.keys, always_install) + end + + ## + # Called from IndexSpecification to get a true Specification + # object. + + def load_spec(name, ver, platform, source) # :nodoc: + key = "#{name}-#{ver}-#{platform}" + + @specs.fetch key do + tuple = Gem::NameTuple.new name, ver, platform + + @specs[key] = source.fetch_spec tuple + end + end + + ## + # Has a local gem for +dep_name+ been added to this set? + + def local?(dep_name) # :nodoc: + spec, _ = @local[dep_name] + + spec + end + + def pretty_print(q) # :nodoc: + q.group 2, "[InstallerSet", "]" do + q.breakable + q.text "domain: #{@domain}" + + q.breakable + q.text "specs: " + q.pp @specs.keys + + q.breakable + q.text "always install: " + q.pp @always_install + end + end + + def remote=(remote) # :nodoc: + case @domain + when :local then + @domain = :both if remote + when :remote then + @domain = nil unless remote + when :both then + @domain = :local unless remote + end + end + + private + + def metadata_satisfied?(spec) + spec.required_ruby_version.satisfied_by?(Gem.ruby_version) && + spec.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + end + + def ensure_required_ruby_version_met(spec) # :nodoc: + if rrv = spec.required_ruby_version + ruby_version = Gem.ruby_version + unless rrv.satisfied_by? ruby_version + raise Gem::RuntimeRequirementNotMetError, + "#{spec.full_name} requires Ruby version #{rrv}. The current ruby version is #{ruby_version}." + end + end + end + + def ensure_required_rubygems_version_met(spec) # :nodoc: + if rrgv = spec.required_rubygems_version + unless rrgv.satisfied_by? Gem.rubygems_version + rg_version = Gem::VERSION + raise Gem::RuntimeRequirementNotMetError, + "#{spec.full_name} requires RubyGems version #{rrgv}. The current RubyGems version is #{rg_version}. " \ + "Try 'gem update --system' to update RubyGems itself." + end + end + end +end diff --git a/lib/rubygems/resolver/local_specification.rb b/lib/rubygems/resolver/local_specification.rb new file mode 100644 index 0000000000..b57d40e795 --- /dev/null +++ b/lib/rubygems/resolver/local_specification.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +## +# A LocalSpecification comes from a .gem file on the local filesystem. + +class Gem::Resolver::LocalSpecification < Gem::Resolver::SpecSpecification + ## + # Returns +true+ if this gem is installable for the current platform. + + def installable_platform? + return true if @source.is_a? Gem::Source::SpecificFile + + super + end + + def local? # :nodoc: + true + end + + def pretty_print(q) # :nodoc: + q.group 2, "[LocalSpecification", "]" do + q.breakable + q.text "name: #{name}" + + q.breakable + q.text "version: #{version}" + + q.breakable + q.text "platform: #{platform}" + + q.breakable + q.text "dependencies:" + q.breakable + q.pp dependencies + + q.breakable + q.text "source: #{@source.path}" + end + end +end diff --git a/lib/rubygems/resolver/lock_set.rb b/lib/rubygems/resolver/lock_set.rb new file mode 100644 index 0000000000..e5ee32a9a6 --- /dev/null +++ b/lib/rubygems/resolver/lock_set.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +## +# A set of gems from a gem dependencies lockfile. + +class Gem::Resolver::LockSet < Gem::Resolver::Set + attr_reader :specs # :nodoc: + + ## + # Creates a new LockSet from the given +sources+ + + def initialize(sources) + super() + + @sources = sources.map do |source| + Gem::Source::Lock.new source + end + + @specs = [] + end + + ## + # Creates a new IndexSpecification in this set using the given +name+, + # +version+ and +platform+. + # + # The specification's set will be the current set, and the source will be + # the current set's source. + + def add(name, version, platform) # :nodoc: + version = Gem::Version.new version + specs = [ + Gem::Resolver::LockSpecification.new(self, name, version, @sources, platform), + ] + + @specs.concat specs + + specs + end + + ## + # Returns an Array of IndexSpecification objects matching the + # DependencyRequest +req+. + + def find_all(req) + @specs.select do |spec| + req.match? spec + end + end + + ## + # Loads a Gem::Specification with the given +name+, +version+ and + # +platform+. +source+ is ignored. + + def load_spec(name, version, platform, source) # :nodoc: + dep = Gem::Dependency.new name, version + + found = @specs.find do |spec| + dep.matches_spec?(spec) && spec.platform == platform + end + + tuple = Gem::NameTuple.new found.name, found.version, found.platform + + found.source.fetch_spec tuple + end + + def pretty_print(q) # :nodoc: + q.group 2, "[LockSet", "]" do + q.breakable + q.text "source:" + + q.breakable + q.pp @source + + q.breakable + q.text "specs:" + + q.breakable + q.pp @specs.map(&:full_name) + end + end +end diff --git a/lib/rubygems/resolver/lock_specification.rb b/lib/rubygems/resolver/lock_specification.rb new file mode 100644 index 0000000000..06f912dd85 --- /dev/null +++ b/lib/rubygems/resolver/lock_specification.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +## +# The LockSpecification comes from a lockfile (Gem::RequestSet::Lockfile). +# +# A LockSpecification's dependency information is pre-filled from the +# lockfile. + +class Gem::Resolver::LockSpecification < Gem::Resolver::Specification + attr_reader :sources + + def initialize(set, name, version, sources, platform) + super() + + @name = name + @platform = platform + @set = set + @source = sources.first + @sources = sources + @version = version + + @dependencies = [] + @spec = nil + end + + ## + # This is a null install as a locked specification is considered installed. + # +options+ are ignored. + + def install(options = {}) + destination = options[:install_dir] || Gem.dir + + if File.exist? File.join(destination, "specifications", spec.spec_name) + yield nil + return + end + + super + end + + ## + # Adds +dependency+ from the lockfile to this specification + + def add_dependency(dependency) # :nodoc: + @dependencies << dependency + end + + def pretty_print(q) # :nodoc: + q.group 2, "[LockSpecification", "]" do + q.breakable + q.text "name: #{@name}" + + q.breakable + q.text "version: #{@version}" + + unless @platform == Gem::Platform::RUBY + q.breakable + q.text "platform: #{@platform}" + end + + unless @dependencies.empty? + q.breakable + q.text "dependencies:" + q.breakable + q.pp @dependencies + end + end + end + + ## + # A specification constructed from the lockfile is returned + + def spec + @spec ||= Gem::Specification.find do |spec| + spec.name == @name && spec.version == @version + end + + @spec ||= Gem::Specification.new do |s| + s.name = @name + s.version = @version + s.platform = @platform + + s.dependencies.concat @dependencies + end + end +end diff --git a/lib/rubygems/resolver/requirement_list.rb b/lib/rubygems/resolver/requirement_list.rb new file mode 100644 index 0000000000..6f86f0f412 --- /dev/null +++ b/lib/rubygems/resolver/requirement_list.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +## +# The RequirementList is used to hold the requirements being considered +# while resolving a set of gems. +# +# The RequirementList acts like a queue where the oldest items are removed +# first. + +class Gem::Resolver::RequirementList + include Enumerable + + ## + # Creates a new RequirementList. + + def initialize + @exact = [] + @list = [] + end + + def initialize_copy(other) # :nodoc: + @exact = @exact.dup + @list = @list.dup + end + + ## + # Adds Resolver::DependencyRequest +req+ to this requirements list. + + def add(req) + if req.requirement.exact? + @exact.push req + else + @list.push req + end + req + end + + ## + # Enumerates requirements in the list + + def each # :nodoc: + return enum_for __method__ unless block_given? + + @exact.each do |requirement| + yield requirement + end + + @list.each do |requirement| + yield requirement + end + end + + ## + # How many elements are in the list + + def size + @exact.size + @list.size + end + + ## + # Is the list empty? + + def empty? + @exact.empty? && @list.empty? + end + + ## + # Remove the oldest DependencyRequest from the list. + + def remove + return @exact.shift unless @exact.empty? + @list.shift + end + + ## + # Returns the oldest five entries from the list. + + def next5 + x = @exact[0,5] + x + @list[0,5 - x.size] + end +end diff --git a/lib/rubygems/resolver/set.rb b/lib/rubygems/resolver/set.rb new file mode 100644 index 0000000000..243fee5fd5 --- /dev/null +++ b/lib/rubygems/resolver/set.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +## +# Resolver sets are used to look up specifications (and their +# dependencies) used in resolution. This set is abstract. + +class Gem::Resolver::Set + ## + # Set to true to disable network access for this set + + attr_accessor :remote + + ## + # Errors encountered when resolving gems + + attr_accessor :errors + + ## + # When true, allows matching of requests to prerelease gems. + + attr_accessor :prerelease + + def initialize # :nodoc: + @prerelease = false + @remote = true + @errors = [] + end + + ## + # The find_all method must be implemented. It returns all Resolver + # Specification objects matching the given DependencyRequest +req+. + + def find_all(req) + raise NotImplementedError + end + + ## + # The #prefetch method may be overridden, but this is not necessary. This + # default implementation does nothing, which is suitable for sets where + # looking up a specification is cheap (such as installed gems). + # + # When overridden, the #prefetch method should look up specifications + # matching +reqs+. + + def prefetch(reqs) + end + + ## + # When true, this set is allowed to access the network when looking up + # specifications or dependencies. + + def remote? # :nodoc: + @remote + end +end diff --git a/lib/rubygems/resolver/source_set.rb b/lib/rubygems/resolver/source_set.rb new file mode 100644 index 0000000000..074b473edc --- /dev/null +++ b/lib/rubygems/resolver/source_set.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +## +# The SourceSet chooses the best available method to query a remote index. +# +# Kind off like BestSet but filters the sources for gems + +class Gem::Resolver::SourceSet < Gem::Resolver::Set + ## + # Creates a SourceSet for the given +sources+ or Gem::sources if none are + # specified. +sources+ must be a Gem::SourceList. + + def initialize + super() + + @links = {} + @sets = {} + end + + def find_all(req) # :nodoc: + if set = get_set(req.dependency.name) + set.find_all req + else + [] + end + end + + # potentially no-op + def prefetch(reqs) # :nodoc: + reqs.each do |req| + if set = get_set(req.dependency.name) + set.prefetch reqs + end + end + end + + def add_source_gem(name, source) + @links[name] = source + end + + private + + def get_set(name) + link = @links[name] + @sets[link] ||= Gem::Source.new(link).dependency_resolver_set(@prerelease) if link + end +end diff --git a/lib/rubygems/resolver/spec_specification.rb b/lib/rubygems/resolver/spec_specification.rb new file mode 100644 index 0000000000..00ef9fdba0 --- /dev/null +++ b/lib/rubygems/resolver/spec_specification.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +## +# The Resolver::SpecSpecification contains common functionality for +# Resolver specifications that are backed by a Gem::Specification. + +class Gem::Resolver::SpecSpecification < Gem::Resolver::Specification + ## + # A SpecSpecification is created for a +set+ for a Gem::Specification in + # +spec+. The +source+ is either where the +spec+ came from, or should be + # loaded from. + + def initialize(set, spec, source = nil) + @set = set + @source = source + @spec = spec + end + + ## + # The dependencies of the gem for this specification + + def dependencies + spec.dependencies + end + + ## + # The required_ruby_version constraint for this specification + + def required_ruby_version + spec.required_ruby_version + end + + ## + # The required_rubygems_version constraint for this specification + + def required_rubygems_version + spec.required_rubygems_version + end + + ## + # The name and version of the specification. + # + # Unlike Gem::Specification#full_name, the platform is not included. + + def full_name + "#{spec.name}-#{spec.version}" + end + + ## + # The name of the gem for this specification + + def name + spec.name + end + + ## + # The platform this gem works on. + + def platform + spec.platform + end + + ## + # The version of the gem for this specification. + + def version + spec.version + end + + ## + # The hash value for this specification. + + def hash + spec.hash + end +end diff --git a/lib/rubygems/resolver/specification.rb b/lib/rubygems/resolver/specification.rb new file mode 100644 index 0000000000..d2098ef0e2 --- /dev/null +++ b/lib/rubygems/resolver/specification.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +## +# A Resolver::Specification contains a subset of the information +# contained in a Gem::Specification. Only the information necessary for +# dependency resolution in the resolver is included. + +class Gem::Resolver::Specification + ## + # The dependencies of the gem for this specification + + attr_reader :dependencies + + ## + # The name of the gem for this specification + + attr_reader :name + + ## + # The platform this gem works on. + + attr_reader :platform + + ## + # The set this specification came from. + + attr_reader :set + + ## + # The source for this specification + + attr_reader :source + + ## + # The Gem::Specification for this Resolver::Specification. + # + # Implementers, note that #install updates @spec, so be sure to cache the + # Gem::Specification in @spec when overriding. + + attr_reader :spec + + ## + # The version of the gem for this specification. + + attr_reader :version + + ## + # The required_ruby_version constraint for this specification. + + attr_reader :required_ruby_version + + ## + # The required_ruby_version constraint for this specification. + + attr_reader :required_rubygems_version + + ## + # Sets default instance variables for the specification. + + def initialize + @dependencies = nil + @name = nil + @platform = nil + @set = nil + @source = nil + @version = nil + @required_ruby_version = Gem::Requirement.default + @required_rubygems_version = Gem::Requirement.default + end + + ## + # Fetches development dependencies if the source does not provide them by + # default (see APISpecification). + + def fetch_development_dependencies # :nodoc: + end + + ## + # The name and version of the specification. + # + # Unlike Gem::Specification#full_name, the platform is not included. + + def full_name + "#{@name}-#{@version}" + end + + ## + # Installs this specification using the Gem::Installer +options+. The + # install method yields a Gem::Installer instance, which indicates the + # gem will be installed, or +nil+, which indicates the gem is already + # installed. + # + # After installation #spec is updated to point to the just-installed + # specification. + + def install(options = {}) + require_relative "../installer" + + gem = download options + + installer = Gem::Installer.at gem, options + + yield installer if block_given? + + @spec = installer.install + end + + def download(options) + dir = options[:install_dir] || Gem.dir + + Gem.ensure_gem_subdirectories dir + + source.download spec, dir + end + + ## + # Returns true if this specification is installable on this platform. + + def installable_platform? + Gem::Platform.match_spec? spec + end + + def local? # :nodoc: + false + end +end diff --git a/lib/rubygems/resolver/strategy.rb b/lib/rubygems/resolver/strategy.rb new file mode 100644 index 0000000000..bf0dbb6adc --- /dev/null +++ b/lib/rubygems/resolver/strategy.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Custom PubGrub strategy with caching for version selection. +# Modeled after Bundler's strategy to avoid redundant versions_for +# calls during the solver's package selection loop. + +class Gem::Resolver::Strategy + def initialize(source) + @source = source + @package_priority_cache = Hash.new {|h, pkg| h[pkg] = {} } + + @version_indexes = Hash.new do |h, k| + if Gem::PubGrub::Package.root?(k) + h[k] = { Gem::PubGrub::Package.root_version => 0 } + else + h[k] = @source.all_versions_for(k).each.with_index.to_h + end + end + end + + def next_package_and_version(unsatisfied) + package, range = next_term_to_try_from(unsatisfied) + [package, most_preferred_version_of(package, range)] + end + + private + + def most_preferred_version_of(package, range) + versions = @source.versions_for(package, range) + indexes = @version_indexes[package] + versions.min_by {|version| indexes[version] || Float::INFINITY } + end + + def next_term_to_try_from(unsatisfied) + unsatisfied.min_by do |package, range| + @package_priority_cache[package][range] ||= begin + matching_versions = @source.versions_for(package, range) + higher_versions = @source.versions_for(package, range.upper_invert) + + [matching_versions.count <= 1 ? 0 : 1, higher_versions.count] + end + end + end +end diff --git a/lib/rubygems/resolver/vendor_set.rb b/lib/rubygems/resolver/vendor_set.rb new file mode 100644 index 0000000000..293a1e3331 --- /dev/null +++ b/lib/rubygems/resolver/vendor_set.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +## +# A VendorSet represents gems that have been unpacked into a specific +# directory that contains a gemspec. +# +# This is used for gem dependency file support. +# +# Example: +# +# set = Gem::Resolver::VendorSet.new +# +# set.add_vendor_gem 'rake', 'vendor/rake' +# +# The directory vendor/rake must contain an unpacked rake gem along with a +# rake.gemspec (watching the given name). + +class Gem::Resolver::VendorSet < Gem::Resolver::Set + ## + # The specifications for this set. + + attr_reader :specs # :nodoc: + + def initialize # :nodoc: + super() + + @directories = {} + @specs = {} + end + + ## + # Adds a specification to the set with the given +name+ which has been + # unpacked into the given +directory+. + + def add_vendor_gem(name, directory) # :nodoc: + gemspec = File.join directory, "#{name}.gemspec" + + spec = Gem::Specification.load gemspec + + raise Gem::GemNotFoundException, + "unable to find #{gemspec} for gem #{name}" unless spec + + spec.full_gem_path = File.expand_path directory + + @specs[spec.name] = spec + @directories[spec] = directory + + spec + end + + ## + # Returns an Array of VendorSpecification objects matching the + # DependencyRequest +req+. + + def find_all(req) + @specs.values.select do |spec| + req.match? spec + end.map do |spec| + source = Gem::Source::Vendor.new @directories[spec] + Gem::Resolver::VendorSpecification.new self, spec, source + end + end + + ## + # Loads a spec with the given +name+. +version+, +platform+ and +source+ are + # ignored. + + def load_spec(name, version, platform, source) # :nodoc: + @specs.fetch name + end + + def pretty_print(q) # :nodoc: + q.group 2, "[VendorSet", "]" do + next if @directories.empty? + q.breakable + + dirs = @directories.map do |spec, directory| + "#{spec.full_name}: #{directory}" + end + + q.seplist dirs do |dir| + q.text dir + end + end + end +end diff --git a/lib/rubygems/resolver/vendor_specification.rb b/lib/rubygems/resolver/vendor_specification.rb new file mode 100644 index 0000000000..ac78f54558 --- /dev/null +++ b/lib/rubygems/resolver/vendor_specification.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +## +# A VendorSpecification represents a gem that has been unpacked into a project +# and is being loaded through a gem dependencies file through the +path:+ +# option. + +class Gem::Resolver::VendorSpecification < Gem::Resolver::SpecSpecification + def ==(other) # :nodoc: + self.class === other && + @set == other.set && + @spec == other.spec && + @source == other.source + end + + ## + # This is a null install as this gem was unpacked into a directory. + # +options+ are ignored. + + def install(options = {}) + yield nil + end +end diff --git a/lib/rubygems/s3_uri_signer.rb b/lib/rubygems/s3_uri_signer.rb new file mode 100644 index 0000000000..148cba38c4 --- /dev/null +++ b/lib/rubygems/s3_uri_signer.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require_relative "openssl" +require_relative "user_interaction" + +## +# S3URISigner implements AWS SigV4 for S3 Source to avoid a dependency on the aws-sdk-* gems +# More on AWS SigV4: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html +class Gem::S3URISigner + include Gem::UserInteraction + + class ConfigurationError < Gem::Exception + def initialize(message) + super message + end + + def to_s # :nodoc: + super.to_s + end + end + + class InstanceProfileError < Gem::Exception + def initialize(message) + super message + end + + def to_s # :nodoc: + super.to_s + end + end + + attr_accessor :uri + attr_accessor :method + + def initialize(uri, method) + @uri = uri + @method = method + end + + ## + # Signs S3 URI using query-params according to the reference: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html + def sign(expiration = 86_400) + s3_config = fetch_s3_config + + current_time = Time.now.utc + date_time = current_time.strftime("%Y%m%dT%H%M%SZ") + date = date_time[0,8] + + credential_info = "#{date}/#{s3_config.region}/s3/aws4_request" + canonical_host = "#{uri.host}.s3.#{s3_config.region}.amazonaws.com" + + query_params = generate_canonical_query_params(s3_config, date_time, credential_info, expiration) + canonical_request = generate_canonical_request(canonical_host, query_params) + string_to_sign = generate_string_to_sign(date_time, credential_info, canonical_request) + signature = generate_signature(s3_config, date, string_to_sign) + + Gem::URI.parse("https://#{canonical_host}#{uri.path}?#{query_params}&X-Amz-Signature=#{signature}") + end + + private + + S3Config = Struct.new :access_key_id, :secret_access_key, :security_token, :region + + def generate_canonical_query_params(s3_config, date_time, credential_info, expiration) + canonical_params = {} + canonical_params["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256" + canonical_params["X-Amz-Credential"] = "#{s3_config.access_key_id}/#{credential_info}" + canonical_params["X-Amz-Date"] = date_time + canonical_params["X-Amz-Expires"] = expiration.to_s + canonical_params["X-Amz-SignedHeaders"] = "host" + canonical_params["X-Amz-Security-Token"] = s3_config.security_token if s3_config.security_token + + # Sorting is required to generate proper signature + canonical_params.sort.to_h.map do |key, value| + "#{base64_uri_escape(key)}=#{base64_uri_escape(value)}" + end.join("&") + end + + def generate_canonical_request(canonical_host, query_params) + [ + method.upcase, + uri.path, + query_params, + "host:#{canonical_host}", + "", # empty params + "host", + "UNSIGNED-PAYLOAD", + ].join("\n") + end + + def generate_string_to_sign(date_time, credential_info, canonical_request) + [ + "AWS4-HMAC-SHA256", + date_time, + credential_info, + OpenSSL::Digest::SHA256.hexdigest(canonical_request), + ].join("\n") + end + + def generate_signature(s3_config, date, string_to_sign) + date_key = OpenSSL::HMAC.digest("sha256", "AWS4" + s3_config.secret_access_key, date) + date_region_key = OpenSSL::HMAC.digest("sha256", date_key, s3_config.region) + date_region_service_key = OpenSSL::HMAC.digest("sha256", date_region_key, "s3") + signing_key = OpenSSL::HMAC.digest("sha256", date_region_service_key, "aws4_request") + OpenSSL::HMAC.hexdigest("sha256", signing_key, string_to_sign) + end + + ## + # Extracts S3 configuration for S3 bucket + def fetch_s3_config + return S3Config.new(uri.user, uri.password, nil, "us-east-1") if uri.user && uri.password + + s3_source = Gem.configuration[:s3_source] || Gem.configuration["s3_source"] + host = uri.host + raise ConfigurationError.new("no s3_source key exists in .gemrc") unless s3_source + + auth = s3_source[host] || s3_source[host.to_sym] + raise ConfigurationError.new("no key for host #{host} in s3_source in .gemrc") unless auth + + provider = auth[:provider] || auth["provider"] + case provider + when "env" + id = ENV["AWS_ACCESS_KEY_ID"] + secret = ENV["AWS_SECRET_ACCESS_KEY"] + security_token = ENV["AWS_SESSION_TOKEN"] + when "instance_profile" + credentials = ec2_metadata_credentials_json + id = credentials["AccessKeyId"] + secret = credentials["SecretAccessKey"] + security_token = credentials["Token"] + else + id = auth[:id] || auth["id"] + secret = auth[:secret] || auth["secret"] + security_token = auth[:security_token] || auth["security_token"] + end + + raise ConfigurationError.new("s3_source for #{host} missing id or secret") unless id && secret + + region = auth[:region] || auth["region"] || "us-east-1" + S3Config.new(id, secret, security_token, region) + end + + def base64_uri_escape(str) + str.gsub(%r{[\+/=\n]}, BASE64_URI_TRANSLATE) + end + + def ec2_metadata_credentials_json + require_relative "vendored_net_http" + require_relative "request" + require_relative "request/connection_pools" + require "json" + + # First try V2 fallback to V1 + res = nil + begin + res = ec2_metadata_credentials_imds_v2 + rescue InstanceProfileError + alert_warning "Unable to access ec2 credentials via IMDSv2, falling back to IMDSv1" + res = ec2_metadata_credentials_imds_v1 + end + res + end + + def ec2_metadata_credentials_imds_v2 + token = ec2_metadata_token + iam_info = ec2_metadata_request(EC2_IAM_INFO, token:) + # Expected format: arn:aws:iam::<id>:instance-profile/<role_name> + role_name = iam_info["InstanceProfileArn"].split("/").last + ec2_metadata_request(EC2_IAM_SECURITY_CREDENTIALS + role_name, token:) + end + + def ec2_metadata_credentials_imds_v1 + iam_info = ec2_metadata_request(EC2_IAM_INFO, token: nil) + # Expected format: arn:aws:iam::<id>:instance-profile/<role_name> + role_name = iam_info["InstanceProfileArn"].split("/").last + ec2_metadata_request(EC2_IAM_SECURITY_CREDENTIALS + role_name, token: nil) + end + + def ec2_metadata_request(url, token:) + request = ec2_iam_request(Gem::URI(url), Gem::Net::HTTP::Get) + + response = request.fetch do |req| + if token + req.add_field "X-aws-ec2-metadata-token", token + end + end + + case response + when Gem::Net::HTTPOK then + JSON.parse(response.body) + else + raise InstanceProfileError.new("Unable to fetch AWS metadata from #{uri}: #{response.message} #{response.code}") + end + end + + def ec2_metadata_token + request = ec2_iam_request(Gem::URI(EC2_IAM_TOKEN), Gem::Net::HTTP::Put) + + response = request.fetch do |req| + req.add_field "X-aws-ec2-metadata-token-ttl-seconds", 60 + end + + case response + when Gem::Net::HTTPOK then + response.body + else + raise InstanceProfileError.new("Unable to fetch AWS metadata from #{uri}: #{response.message} #{response.code}") + end + end + + def ec2_iam_request(uri, verb) + @request_pool ||= create_request_pool(uri) + Gem::Request.new(uri, verb, nil, @request_pool) + end + + def create_request_pool(uri) + proxy_uri = Gem::Request.proxy_uri(Gem::Request.get_proxy_from_env(uri.scheme)) + certs = Gem::Request.get_cert_files + Gem::Request::ConnectionPools.new(proxy_uri, certs).pool_for(uri) + end + + BASE64_URI_TRANSLATE = { "+" => "%2B", "/" => "%2F", "=" => "%3D", "\n" => "" }.freeze + EC2_IAM_TOKEN = "http://169.254.169.254/latest/api/token" + EC2_IAM_INFO = "http://169.254.169.254/latest/meta-data/iam/info" + EC2_IAM_SECURITY_CREDENTIALS = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" +end diff --git a/lib/rubygems/safe_marshal.rb b/lib/rubygems/safe_marshal.rb new file mode 100644 index 0000000000..871f24727d --- /dev/null +++ b/lib/rubygems/safe_marshal.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "stringio" + +require_relative "safe_marshal/reader" +require_relative "safe_marshal/visitors/to_ruby" + +module Gem + ### + # This module is used for safely loading Marshal specs from a gem. The + # `safe_load` method defined on this module is specifically designed for + # loading Gem specifications. + + module SafeMarshal + PERMITTED_CLASSES = %w[ + Date + Time + Rational + + Gem::Dependency + Gem::NameTuple + Gem::Platform + Gem::Requirement + Gem::Specification + Gem::Version + Gem::Version::Requirement + + YAML::Syck::DefaultKey + YAML::PrivateType + ].freeze + private_constant :PERMITTED_CLASSES + + PERMITTED_SYMBOLS = %w[ + development + runtime + + name + number + platform + dependencies + ].freeze + private_constant :PERMITTED_SYMBOLS + + PERMITTED_IVARS = { + "String" => %w[E encoding @taguri @debug_created_info], + "Time" => %w[ + offset zone nano_num nano_den submicro + @_zone @marshal_with_utc_coercion + ], + "Gem::Dependency" => %w[ + @name @requirement @prerelease @version_requirement @version_requirements @type + @force_ruby_platform + ], + "Gem::NameTuple" => %w[@name @version @platform], + "Gem::Platform" => %w[@os @cpu @version], + "Psych::PrivateType" => %w[@value @type_id], + "YAML::PrivateType" => %w[@value @type_id], + }.freeze + private_constant :PERMITTED_IVARS + + def self.safe_load(input) + load(input, permitted_classes: PERMITTED_CLASSES, permitted_symbols: PERMITTED_SYMBOLS, permitted_ivars: PERMITTED_IVARS) + end + + def self.load(input, permitted_classes: [::Symbol], permitted_symbols: [], permitted_ivars: {}) + root = Reader.new(StringIO.new(input, "r").binmode).read! + + Visitors::ToRuby.new( + permitted_classes: permitted_classes, + permitted_symbols: permitted_symbols, + permitted_ivars: permitted_ivars, + ).visit(root) + end + end +end diff --git a/lib/rubygems/safe_marshal/elements.rb b/lib/rubygems/safe_marshal/elements.rb new file mode 100644 index 0000000000..f8874b1b2f --- /dev/null +++ b/lib/rubygems/safe_marshal/elements.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Gem + module SafeMarshal + module Elements + class Element + end + + class Symbol < Element + def initialize(name) + @name = name + end + attr_reader :name + end + + class UserDefined < Element + def initialize(name, binary_string) + @name = name + @binary_string = binary_string + end + + attr_reader :name, :binary_string + end + + class UserMarshal < Element + def initialize(name, data) + @name = name + @data = data + end + + attr_reader :name, :data + end + + class String < Element + def initialize(str) + @str = str + end + + attr_reader :str + end + + class Hash < Element + def initialize(pairs) + @pairs = pairs + end + + attr_reader :pairs + end + + class HashWithDefaultValue < Hash + def initialize(pairs, default) + super(pairs) + @default = default + end + + attr_reader :default + end + + class Array < Element + def initialize(elements) + @elements = elements + end + + attr_reader :elements + end + + class Integer < Element + def initialize(int) + @int = int + end + + attr_reader :int + end + + class True < Element + def initialize + end + TRUE = new.freeze + end + + class False < Element + def initialize + end + + FALSE = new.freeze + end + + class WithIvars < Element + def initialize(object, ivars) + @object = object + @ivars = ivars + end + + attr_reader :object, :ivars + end + + class Object < Element + def initialize(name) + @name = name + end + attr_reader :name + end + + class Nil < Element + NIL = new.freeze + end + + class ObjectLink < Element + def initialize(offset) + @offset = offset + end + attr_reader :offset + end + + class SymbolLink < Element + def initialize(offset) + @offset = offset + end + attr_reader :offset + end + + class Float < Element + def initialize(string) + @string = string + end + attr_reader :string + end + + class Bignum < Element # rubocop:disable Lint/UnifiedInteger + def initialize(sign, data) + @sign = sign + @data = data + end + attr_reader :sign, :data + end + + class UserClass < Element + def initialize(name, wrapped_object) + @name = name + @wrapped_object = wrapped_object + end + attr_reader :name, :wrapped_object + end + end + end +end diff --git a/lib/rubygems/safe_marshal/reader.rb b/lib/rubygems/safe_marshal/reader.rb new file mode 100644 index 0000000000..4362d65fd6 --- /dev/null +++ b/lib/rubygems/safe_marshal/reader.rb @@ -0,0 +1,325 @@ +# frozen_string_literal: true + +require_relative "elements" + +module Gem + module SafeMarshal + class Reader + class Error < StandardError + end + + class UnsupportedVersionError < Error + end + + class UnconsumedBytesError < Error + end + + class NotImplementedError < Error + end + + class EOFError < Error + end + + class DataTooShortError < Error + end + + class NegativeLengthError < Error + end + + def initialize(io) + @io = io + end + + def read! + read_header + root = read_element + raise UnconsumedBytesError, "expected EOF, got #{@io.read(10).inspect}... after top-level element #{root.class}" unless @io.eof? + root + end + + private + + MARSHAL_VERSION = [Marshal::MAJOR_VERSION, Marshal::MINOR_VERSION].map(&:chr).join.freeze + private_constant :MARSHAL_VERSION + + def read_header + v = @io.read(2) + raise UnsupportedVersionError, "Unsupported marshal version #{v.bytes.map(&:ord).join(".")}, expected #{Marshal::MAJOR_VERSION}.#{Marshal::MINOR_VERSION}" unless v == MARSHAL_VERSION + end + + def read_bytes(n) + raise NegativeLengthError if n < 0 + str = @io.read(n) + raise EOFError, "expected #{n} bytes, got EOF" if str.nil? + raise DataTooShortError, "expected #{n} bytes, got #{str.inspect}" unless str.bytesize == n + str + end + + def read_byte + @io.getbyte || raise(EOFError, "Unexpected EOF") + end + + def read_integer + b = read_byte + + case b + when 0x00 + 0 + when 0x01 + read_byte + when 0x02 + read_byte | (read_byte << 8) + when 0x03 + read_byte | (read_byte << 8) | (read_byte << 16) + when 0x04 + read_byte | (read_byte << 8) | (read_byte << 16) | (read_byte << 24) + when 0xFC + read_byte | (read_byte << 8) | (read_byte << 16) | (read_byte << 24) | -0x100000000 + when 0xFD + read_byte | (read_byte << 8) | (read_byte << 16) | -0x1000000 + when 0xFE + read_byte | (read_byte << 8) | -0x10000 + when 0xFF + read_byte | -0x100 + else + signed = (b ^ 128) - 128 + if b >= 128 + signed + 5 + else + signed - 5 + end + end + end + + def read_element + type = read_byte + case type + when 34 then read_string # ?" + when 48 then read_nil # ?0 + when 58 then read_symbol # ?: + when 59 then read_symbol_link # ?; + when 64 then read_object_link # ?@ + when 70 then read_false # ?F + when 73 then read_object_with_ivars # ?I + when 84 then read_true # ?T + when 85 then read_user_marshal # ?U + when 91 then read_array # ?[ + when 102 then read_float # ?f + when 105 then Elements::Integer.new(read_integer) # ?i + when 108 then read_bignum # ?l + when 111 then read_object # ?o + when 117 then read_user_defined # ?u + when 123 then read_hash # ?{ + when 125 then read_hash_with_default_value # ?} + when 101 then read_extended_object # ?e + when 99 then read_class # ?c + when 109 then read_module # ?m + when 77 then read_class_or_module # ?M + when 100 then read_data # ?d + when 47 then read_regexp # ?/ + when 83 then read_struct # ?S + when 67 then read_user_class # ?C + else + raise Error, "Unknown marshal type discriminator #{type.chr.inspect} (#{type})" + end + end + + STRING_E_SYMBOL = Elements::Symbol.new("E").freeze + private_constant :STRING_E_SYMBOL + + def read_symbol + len = read_integer + if len == 1 + byte = read_byte + if byte == 69 # ?E + STRING_E_SYMBOL + else + Elements::Symbol.new(byte.chr) + end + else + name = read_bytes(len) + Elements::Symbol.new(name) + end + end + + EMPTY_STRING = Elements::String.new("".b.freeze).freeze + private_constant :EMPTY_STRING + + def read_string + length = read_integer + return EMPTY_STRING if length == 0 + str = read_bytes(length) + Elements::String.new(str) + end + + def read_true + Elements::True::TRUE + end + + def read_false + Elements::False::FALSE + end + + def read_user_defined + name = read_element + binary_string = read_bytes(read_integer) + Elements::UserDefined.new(name, binary_string) + end + + EMPTY_ARRAY = Elements::Array.new([].freeze).freeze + private_constant :EMPTY_ARRAY + + def read_array + length = read_integer + return EMPTY_ARRAY if length == 0 + raise NegativeLengthError if length < 0 + elements = Array.new(length) do + read_element + end + Elements::Array.new(elements) + end + + def read_object_with_ivars + object = read_element + length = read_integer + raise NegativeLengthError if length < 0 + ivars = Array.new(length) do + [read_element, read_element] + end + Elements::WithIvars.new(object, ivars) + end + + def read_symbol_link + offset = read_integer + Elements::SymbolLink.new(offset) + end + + def read_user_marshal + name = read_element + data = read_element + Elements::UserMarshal.new(name, data) + end + + # profiling bundle install --full-index shows that + # offset 6 is by far the most common object link, + # so we special case it to avoid allocating a new + # object a third of the time. + # the following are all the object links that + # appear more than 10000 times in my profiling + + OBJECT_LINKS = { + 6 => Elements::ObjectLink.new(6).freeze, + 30 => Elements::ObjectLink.new(30).freeze, + 81 => Elements::ObjectLink.new(81).freeze, + 34 => Elements::ObjectLink.new(34).freeze, + 38 => Elements::ObjectLink.new(38).freeze, + 50 => Elements::ObjectLink.new(50).freeze, + 91 => Elements::ObjectLink.new(91).freeze, + 42 => Elements::ObjectLink.new(42).freeze, + 46 => Elements::ObjectLink.new(46).freeze, + 150 => Elements::ObjectLink.new(150).freeze, + 100 => Elements::ObjectLink.new(100).freeze, + 104 => Elements::ObjectLink.new(104).freeze, + 108 => Elements::ObjectLink.new(108).freeze, + 242 => Elements::ObjectLink.new(242).freeze, + 246 => Elements::ObjectLink.new(246).freeze, + 139 => Elements::ObjectLink.new(139).freeze, + 143 => Elements::ObjectLink.new(143).freeze, + 114 => Elements::ObjectLink.new(114).freeze, + 308 => Elements::ObjectLink.new(308).freeze, + 200 => Elements::ObjectLink.new(200).freeze, + 54 => Elements::ObjectLink.new(54).freeze, + 62 => Elements::ObjectLink.new(62).freeze, + 1_286_245 => Elements::ObjectLink.new(1_286_245).freeze, + }.freeze + private_constant :OBJECT_LINKS + + def read_object_link + offset = read_integer + OBJECT_LINKS[offset] || Elements::ObjectLink.new(offset) + end + + EMPTY_HASH = Elements::Hash.new([].freeze).freeze + private_constant :EMPTY_HASH + + def read_hash + length = read_integer + return EMPTY_HASH if length == 0 + pairs = Array.new(length) do + [read_element, read_element] + end + Elements::Hash.new(pairs) + end + + def read_hash_with_default_value + length = read_integer + raise NegativeLengthError if length < 0 + pairs = Array.new(length) do + [read_element, read_element] + end + default = read_element + Elements::HashWithDefaultValue.new(pairs, default) + end + + def read_object + name = read_element + object = Elements::Object.new(name) + length = read_integer + raise NegativeLengthError if length < 0 + ivars = Array.new(length) do + [read_element, read_element] + end + Elements::WithIvars.new(object, ivars) + end + + def read_nil + Elements::Nil::NIL + end + + def read_float + string = read_bytes(read_integer) + Elements::Float.new(string) + end + + def read_bignum + sign = read_byte + data = read_bytes(read_integer * 2) + Elements::Bignum.new(sign, data) + end + + def read_extended_object + raise NotImplementedError, "Reading Marshal objects of type extended_object is not implemented" + end + + def read_class + raise NotImplementedError, "Reading Marshal objects of type class is not implemented" + end + + def read_module + raise NotImplementedError, "Reading Marshal objects of type module is not implemented" + end + + def read_class_or_module + raise NotImplementedError, "Reading Marshal objects of type class_or_module is not implemented" + end + + def read_data + raise NotImplementedError, "Reading Marshal objects of type data is not implemented" + end + + def read_regexp + raise NotImplementedError, "Reading Marshal objects of type regexp is not implemented" + end + + def read_struct + raise NotImplementedError, "Reading Marshal objects of type struct is not implemented" + end + + def read_user_class + name = read_element + wrapped_object = read_element + Elements::UserClass.new(name, wrapped_object) + end + end + end +end diff --git a/lib/rubygems/safe_marshal/visitors/stream_printer.rb b/lib/rubygems/safe_marshal/visitors/stream_printer.rb new file mode 100644 index 0000000000..162b36ad05 --- /dev/null +++ b/lib/rubygems/safe_marshal/visitors/stream_printer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "visitor" + +module Gem::SafeMarshal + module Visitors + class StreamPrinter < Visitor + def initialize(io, indent: "") + @io = io + @indent = indent + @level = 0 + end + + def visit(target) + @io.write("#{@indent * @level}#{target.class}") + target.instance_variables.each do |ivar| + value = target.instance_variable_get(ivar) + next if Elements::Element === value || Array === value + @io.write(" #{ivar}=#{value.inspect}") + end + @io.write("\n") + begin + @level += 1 + super + ensure + @level -= 1 + end + end + end + end +end diff --git a/lib/rubygems/safe_marshal/visitors/to_ruby.rb b/lib/rubygems/safe_marshal/visitors/to_ruby.rb new file mode 100644 index 0000000000..a1f9481776 --- /dev/null +++ b/lib/rubygems/safe_marshal/visitors/to_ruby.rb @@ -0,0 +1,428 @@ +# frozen_string_literal: true + +require_relative "visitor" + +module Gem::SafeMarshal + module Visitors + class ToRuby < Visitor + def initialize(permitted_classes:, permitted_symbols:, permitted_ivars:) + @permitted_classes = permitted_classes + @permitted_symbols = ["E"].concat(permitted_symbols).concat(permitted_classes) + @permitted_ivars = permitted_ivars + + @objects = [] + @symbols = [] + @class_cache = {} + + @stack = ["root"] + @stack_idx = 1 + end + + def inspect # :nodoc: + format("#<%s permitted_classes: %p permitted_symbols: %p permitted_ivars: %p>", + self.class, @permitted_classes, @permitted_symbols, @permitted_ivars) + end + + def visit(target) + stack_idx = @stack_idx + super + ensure + @stack_idx = stack_idx - 1 + end + + private + + def push_stack(element) + @stack[@stack_idx] = element + @stack_idx += 1 + end + + def visit_Gem_SafeMarshal_Elements_Array(a) + array = register_object([]) + + elements = a.elements + size = elements.size + idx = 0 + # not idiomatic, but there's a huge number of IMEMOs allocated here, so we avoid the block + # because this is such a hot path when doing a bundle install with the full index + while idx < size + push_stack idx + array << visit(elements[idx]) + idx += 1 + end + + array + end + + def visit_Gem_SafeMarshal_Elements_Symbol(s) + name = s.name + raise UnpermittedSymbolError.new(symbol: name, stack: formatted_stack) unless @permitted_symbols.include?(name) + visit_symbol_type(s) + end + + def map_ivars(klass, ivars) + stack_idx = @stack_idx + ivars.map.with_index do |(k, v), i| + @stack_idx = stack_idx + + push_stack "ivar_" + push_stack i + k = resolve_ivar(klass, k) + + @stack_idx = stack_idx + push_stack k + + next k, visit(v) + end + end + + def visit_Gem_SafeMarshal_Elements_WithIvars(e) + object_offset = @objects.size + push_stack "object" + object = visit(e.object) + ivars = map_ivars(object.class, e.ivars) + + case e.object + when Elements::UserDefined + if object.class == ::Time + internal = [] + + ivars.reject! do |k, v| + case k + when :offset, :zone, :nano_num, :nano_den, :submicro + internal << [k, v] + true + else + false + end + end + + s = e.object.binary_string + # 122 is the largest integer that can be represented in marshal in a single byte + raise TimeTooLargeError.new("binary string too large", stack: formatted_stack) if s.bytesize > 122 + + marshal_string = "\x04\bIu:\tTime".b + marshal_string.concat(s.bytesize + 5) + marshal_string << s + # internal is limited to 5, so no overflow is possible + marshal_string.concat(internal.size + 5) + + internal.each do |k, v| + k = k.name + # ivar name can't be too large because only known ivars are in the internal ivars list + marshal_string.concat(":") + marshal_string.concat(k.bytesize + 5) + marshal_string.concat(k) + dumped = Marshal.dump(v) + dumped[0, 2] = "" + marshal_string.concat(dumped) + end + + object = @objects[object_offset] = Marshal.load(marshal_string) + end + when Elements::String + enc = nil + + ivars.reject! do |k, v| + case k + when :E + case v + when TrueClass + enc = "UTF-8" + when FalseClass + enc = "US-ASCII" + else + raise FormatError, "Unexpected value for String :E #{v.inspect}" + end + when :encoding + enc = v + else + next false + end + true + end + + object.force_encoding(enc) if enc + end + + ivars.each do |k, v| + object.instance_variable_set k, v + end + object + end + + def visit_Gem_SafeMarshal_Elements_Hash(o) + hash = register_object({}) + + o.pairs.each_with_index do |(k, v), i| + push_stack i + k = visit(k) + push_stack k + hash[k] = visit(v) + end + + hash + end + + def visit_Gem_SafeMarshal_Elements_HashWithDefaultValue(o) + hash = visit_Gem_SafeMarshal_Elements_Hash(o) + push_stack :default + hash.default = visit(o.default) + hash + end + + def visit_Gem_SafeMarshal_Elements_Object(o) + register_object(resolve_class(o.name).allocate) + end + + def visit_Gem_SafeMarshal_Elements_ObjectLink(o) + @objects.fetch(o.offset) + end + + def visit_Gem_SafeMarshal_Elements_SymbolLink(o) + @symbols.fetch(o.offset) + end + + def visit_Gem_SafeMarshal_Elements_UserDefined(o) + register_object(call_method(resolve_class(o.name), :_load, o.binary_string)) + end + + def visit_Gem_SafeMarshal_Elements_UserMarshal(o) + klass = resolve_class(o.name) + compat = COMPAT_CLASSES.fetch(klass, nil) + idx = @objects.size + object = register_object(call_method(compat || klass, :allocate)) + + push_stack :data + ret = call_method(object, :marshal_load, visit(o.data)) + + if compat + object = @objects[idx] = ret + end + + object + end + + def visit_Gem_SafeMarshal_Elements_Integer(i) + i.int + end + + def visit_Gem_SafeMarshal_Elements_Nil(_) + nil + end + + def visit_Gem_SafeMarshal_Elements_True(_) + true + end + + def visit_Gem_SafeMarshal_Elements_False(_) + false + end + + def visit_Gem_SafeMarshal_Elements_String(s) + register_object(+s.str) + end + + def visit_Gem_SafeMarshal_Elements_Float(f) + register_object( + case f.string + when "inf" + ::Float::INFINITY + when "-inf" + -::Float::INFINITY + when "nan" + ::Float::NAN + else + f.string.to_f + end + ) + end + + def visit_Gem_SafeMarshal_Elements_Bignum(b) + result = 0 + b.data.each_byte.with_index do |byte, exp| + result += (byte * 2**(exp * 8)) + end + + case b.sign + when 43 # ?+ + result + when 45 # ?- + -result + else + raise FormatError, "Unexpected sign for Bignum #{b.sign.chr.inspect} (#{b.sign})" + end + end + + def visit_Gem_SafeMarshal_Elements_UserClass(r) + if resolve_class(r.name) == ::Hash && r.wrapped_object.is_a?(Elements::Hash) + + hash = register_object({}.compare_by_identity) + + o = r.wrapped_object + o.pairs.each_with_index do |(k, v), i| + push_stack i + k = visit(k) + push_stack k + hash[k] = visit(v) + end + + if o.is_a?(Elements::HashWithDefaultValue) + push_stack :default + hash.default = visit(o.default) + end + + hash + else + raise UnsupportedError.new("Unsupported user class #{resolve_class(r.name)} in marshal stream", stack: formatted_stack) + end + end + + def resolve_class(n) + @class_cache[n] ||= begin + to_s = resolve_symbol_name(n) + raise UnpermittedClassError.new(name: to_s, stack: formatted_stack) unless @permitted_classes.include?(to_s) + visit_symbol_type(n) + begin + ::Object.const_get(to_s) + rescue NameError + raise ArgumentError, "Undefined class #{to_s.inspect}" + end + end + end + + class RationalCompat + def marshal_load(s) + num, den = s + raise ArgumentError, "Expected 2 ints" unless s.size == 2 && num.is_a?(Integer) && den.is_a?(Integer) + Rational(num, den) + end + end + private_constant :RationalCompat + + COMPAT_CLASSES = {}.tap do |h| + h[Rational] = RationalCompat + end.compare_by_identity.freeze + private_constant :COMPAT_CLASSES + + def resolve_ivar(klass, name) + to_s = resolve_symbol_name(name) + + raise UnpermittedIvarError.new(symbol: to_s, klass: klass, stack: formatted_stack) unless @permitted_ivars.fetch(klass.name, [].freeze).include?(to_s) + + visit_symbol_type(name) + end + + def visit_symbol_type(element) + case element + when Elements::Symbol + sym = element.name.to_sym + @symbols << sym + sym + when Elements::SymbolLink + visit_Gem_SafeMarshal_Elements_SymbolLink(element) + end + end + + # This is a hot method, so avoid respond_to? checks on every invocation + if :read.respond_to?(:name) + def resolve_symbol_name(element) + case element + when Elements::Symbol + element.name + when Elements::SymbolLink + visit_Gem_SafeMarshal_Elements_SymbolLink(element).name + else + raise FormatError, "Expected symbol or symbol link, got #{element.inspect} @ #{formatted_stack.join(".")}" + end + end + else + def resolve_symbol_name(element) + case element + when Elements::Symbol + element.name + when Elements::SymbolLink + visit_Gem_SafeMarshal_Elements_SymbolLink(element).to_s + else + raise FormatError, "Expected symbol or symbol link, got #{element.inspect} @ #{formatted_stack.join(".")}" + end + end + end + + def register_object(o) + @objects << o + o + end + + def call_method(receiver, method, *args) + receiver.__send__(method, *args) + rescue NoMethodError => e + raise unless e.receiver == receiver + + raise MethodCallError, "Unable to call #{method.inspect} on #{receiver.inspect}, perhaps it is a class using marshal compat, which is not visible in ruby? #{e}" + end + + def formatted_stack + formatted = [] + @stack[0, @stack_idx].each do |e| + if e.is_a?(Integer) + if formatted.last == "ivar_" + formatted[-1] = "ivar_#{e}" + else + formatted << "[#{e}]" + end + else + formatted << e + end + end + formatted + end + + class Error < StandardError + end + + class TimeTooLargeError < Error + def initialize(message, stack:) + super "#{message} @ #{stack.join "."}" + end + end + + class UnpermittedSymbolError < Error + def initialize(symbol:, stack:) + @symbol = symbol + @stack = stack + super "Attempting to load unpermitted symbol #{symbol.inspect} @ #{stack.join "."}" + end + end + + class UnpermittedIvarError < Error + def initialize(symbol:, klass:, stack:) + @symbol = symbol + @klass = klass + @stack = stack + super "Attempting to set unpermitted ivar #{symbol.inspect} on object of class #{klass} @ #{stack.join "."}" + end + end + + class UnpermittedClassError < Error + def initialize(name:, stack:) + @name = name + @stack = stack + super "Attempting to load unpermitted class #{name.inspect} @ #{stack.join "."}" + end + end + + class UnsupportedError < Error + def initialize(message, stack:) + super "#{message} @ #{stack.join "."}" + end + end + + class FormatError < Error + end + + class MethodCallError < Error + end + end + end +end diff --git a/lib/rubygems/safe_marshal/visitors/visitor.rb b/lib/rubygems/safe_marshal/visitors/visitor.rb new file mode 100644 index 0000000000..c9a079dc0e --- /dev/null +++ b/lib/rubygems/safe_marshal/visitors/visitor.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Gem::SafeMarshal::Visitors + class Visitor + def visit(target) + send DISPATCH.fetch(target.class), target + end + + private + + DISPATCH = Gem::SafeMarshal::Elements.constants.each_with_object({}) do |c, h| + next if c == :Element + + klass = Gem::SafeMarshal::Elements.const_get(c) + h[klass] = :"visit_#{klass.name.gsub("::", "_")}" + h.default = :visit_unknown_element + end.compare_by_identity.freeze + private_constant :DISPATCH + + def visit_unknown_element(e) + raise ArgumentError, "Attempting to visit unknown element #{e.inspect}" + end + + def visit_Gem_SafeMarshal_Elements_Array(target) + target.elements.each {|e| visit(e) } + end + + def visit_Gem_SafeMarshal_Elements_Bignum(target); end + def visit_Gem_SafeMarshal_Elements_False(target); end + def visit_Gem_SafeMarshal_Elements_Float(target); end + + def visit_Gem_SafeMarshal_Elements_Hash(target) + target.pairs.each do |k, v| + visit(k) + visit(v) + end + end + + def visit_Gem_SafeMarshal_Elements_HashWithDefaultValue(target) + visit_Gem_SafeMarshal_Elements_Hash(target) + visit(target.default) + end + + def visit_Gem_SafeMarshal_Elements_Integer(target); end + def visit_Gem_SafeMarshal_Elements_Nil(target); end + + def visit_Gem_SafeMarshal_Elements_Object(target) + visit(target.name) + end + + def visit_Gem_SafeMarshal_Elements_ObjectLink(target); end + def visit_Gem_SafeMarshal_Elements_String(target); end + def visit_Gem_SafeMarshal_Elements_Symbol(target); end + def visit_Gem_SafeMarshal_Elements_SymbolLink(target); end + def visit_Gem_SafeMarshal_Elements_True(target); end + + def visit_Gem_SafeMarshal_Elements_UserDefined(target) + visit(target.name) + end + + def visit_Gem_SafeMarshal_Elements_UserMarshal(target) + visit(target.name) + visit(target.data) + end + + def visit_Gem_SafeMarshal_Elements_WithIvars(target) + visit(target.object) + target.ivars.each do |k, v| + visit(k) + visit(v) + end + end + end +end diff --git a/lib/rubygems/safe_yaml.rb b/lib/rubygems/safe_yaml.rb new file mode 100644 index 0000000000..f4bba00136 --- /dev/null +++ b/lib/rubygems/safe_yaml.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Gem + ### + # This module is used for safely loading YAML specs from a gem. The + # `safe_load` method defined on this module is specifically designed for + # loading Gem specifications. For loading other YAML safely, please see + # Psych.safe_load + + module SafeYAML + PERMITTED_CLASSES = %w[ + Symbol + Time + Date + Gem::Dependency + Gem::Platform + Gem::Requirement + Gem::Specification + Gem::Version + Gem::Version::Requirement + ].freeze + + PERMITTED_SYMBOLS = %w[ + development + runtime + ].freeze + + @aliases_enabled = true + def self.aliases_enabled=(value) # :nodoc: + @aliases_enabled = !!value + end + + def self.aliases_enabled? # :nodoc: + @aliases_enabled + end + + def self.safe_load(input) + if Gem.use_psych? + ::Psych.safe_load(input, permitted_classes: PERMITTED_CLASSES, + permitted_symbols: PERMITTED_SYMBOLS, aliases: @aliases_enabled) + else + Gem::YAMLSerializer.load( + input, + permitted_classes: PERMITTED_CLASSES, + permitted_symbols: PERMITTED_SYMBOLS, + aliases: aliases_enabled? + ) + end + end + + class << self + alias_method :load, :safe_load + end + end +end diff --git a/lib/rubygems/security.rb b/lib/rubygems/security.rb new file mode 100644 index 0000000000..69ba87b07f --- /dev/null +++ b/lib/rubygems/security.rb @@ -0,0 +1,615 @@ +# 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 "exceptions" +require_relative "openssl" + +## +# = Signing gems +# +# The Gem::Security implements cryptographic signatures for gems. The section +# below is a step-by-step guide to using signed gems and generating your own. +# +# == Walkthrough +# +# === Building your certificate +# +# In order to start signing your gems, you'll need to build a private key and +# a self-signed certificate. Here's how: +# +# # build a private key and certificate for yourself: +# $ gem cert --build you@example.com +# +# This could take anywhere from a few seconds to a minute or two, depending on +# the speed of your computer (public key algorithms aren't exactly the +# speediest crypto algorithms in the world). When it's finished, you'll see +# the files "gem-private_key.pem" and "gem-public_cert.pem" in the current +# directory. +# +# First things first: Move both files to ~/.gem if you don't already have a +# key and certificate in that directory. Ensure the file permissions make the +# key unreadable by others (by default the file is saved securely). +# +# Keep your private key hidden; if it's compromised, someone can sign packages +# as you (note: PKI has ways of mitigating the risk of stolen keys; more on +# that later). +# +# === Signing Gems +# +# In RubyGems 2 and newer there is no extra work to sign a gem. RubyGems will +# automatically find your key and certificate in your home directory and use +# them to sign newly packaged gems. +# +# If your certificate is not self-signed (signed by a third party) RubyGems +# will attempt to load the certificate chain from the trusted certificates. +# Use <code>gem cert --add signing_cert.pem</code> to add your signers as +# trusted certificates. See below for further information on certificate +# chains. +# +# If you build your gem it will automatically be signed. If you peek inside +# your gem file, you'll see a couple of new files have been added: +# +# $ tar tf your-gem-1.0.gem +# metadata.gz +# metadata.gz.sig # metadata signature +# data.tar.gz +# data.tar.gz.sig # data signature +# checksums.yaml.gz +# checksums.yaml.gz.sig # checksums signature +# +# === Manually signing gems +# +# If you wish to store your key in a separate secure location you'll need to +# set your gems up for signing by hand. To do this, set the +# <code>signing_key</code> and <code>cert_chain</code> in the gemspec before +# packaging your gem: +# +# s.signing_key = '/secure/path/to/gem-private_key.pem' +# s.cert_chain = %w[/secure/path/to/gem-public_cert.pem] +# +# When you package your gem with these options set RubyGems will automatically +# load your key and certificate from the secure paths. +# +# === Signed gems and security policies +# +# Now let's verify the signature. Go ahead and install the gem, but add the +# following options: <code>-P HighSecurity</code>, like this: +# +# # install the gem with using the security policy "HighSecurity" +# $ sudo gem install your.gem -P HighSecurity +# +# The <code>-P</code> option sets your security policy -- we'll talk about +# that in just a minute. Eh, what's this? +# +# $ gem install -P HighSecurity your-gem-1.0.gem +# ERROR: While executing gem ... (Gem::Security::Exception) +# root cert /CN=you/DC=example is not trusted +# +# The culprit here is the security policy. RubyGems has several different +# security policies. Let's take a short break and go over the security +# policies. Here's a list of the available security policies, and a brief +# description of each one: +# +# * NoSecurity - Well, no security at all. Signed packages are treated like +# unsigned packages. +# * LowSecurity - Pretty much no security. If a package is signed then +# RubyGems will make sure the signature matches the signing +# certificate, and that the signing certificate hasn't expired, but +# that's it. A malicious user could easily circumvent this kind of +# security. +# * MediumSecurity - Better than LowSecurity and NoSecurity, but still +# fallible. Package contents are verified against the signing +# certificate, and the signing certificate is checked for validity, +# and checked against the rest of the certificate chain (if you don't +# know what a certificate chain is, stay tuned, we'll get to that). +# The biggest improvement over LowSecurity is that MediumSecurity +# won't install packages that are signed by untrusted sources. +# Unfortunately, MediumSecurity still isn't totally secure -- a +# malicious user can still unpack the gem, strip the signatures, and +# distribute the gem unsigned. +# * HighSecurity - Here's the bugger that got us into this mess. +# The HighSecurity policy is identical to the MediumSecurity policy, +# except that it does not allow unsigned gems. A malicious user +# doesn't have a whole lot of options here; they can't modify the +# package contents without invalidating the signature, and they can't +# modify or remove signature or the signing certificate chain, or +# RubyGems will simply refuse to install the package. Oh well, maybe +# they'll have better luck causing problems for CPAN users instead :). +# +# The reason RubyGems refused to install your shiny new signed gem was because +# it was from an untrusted source. Well, your code is infallible (naturally), +# so you need to add yourself as a trusted source: +# +# # add trusted certificate +# gem cert --add ~/.gem/gem-public_cert.pem +# +# You've now added your public certificate as a trusted source. Now you can +# install packages signed by your private key without any hassle. Let's try +# the install command above again: +# +# # install the gem with using the HighSecurity policy (and this time +# # without any shenanigans) +# $ gem install -P HighSecurity your-gem-1.0.gem +# Successfully installed your-gem-1.0 +# 1 gem installed +# +# This time RubyGems will accept your signed package and begin installing. +# +# While you're waiting for RubyGems to work it's magic, have a look at some of +# the other security commands by running <code>gem help cert</code>: +# +# Options: +# -a, --add CERT Add a trusted certificate. +# -l, --list [FILTER] List trusted certificates where the +# subject contains FILTER +# -r, --remove FILTER Remove trusted certificates where the +# subject contains FILTER +# -b, --build EMAIL_ADDR Build private key and self-signed +# certificate for EMAIL_ADDR +# -C, --certificate CERT Signing certificate for --sign +# -K, --private-key KEY Key for --sign or --build +# -A, --key-algorithm ALGORITHM Select key algorithm for --build from RSA, DSA, or EC. Defaults to RSA. +# -s, --sign CERT Signs CERT with the key from -K +# and the certificate from -C +# -d, --days NUMBER_OF_DAYS Days before the certificate expires +# -R, --re-sign Re-signs the certificate from -C with the key from -K +# +# We've already covered the <code>--build</code> option, and the +# <code>--add</code>, <code>--list</code>, and <code>--remove</code> commands +# seem fairly straightforward; they allow you to add, list, and remove the +# certificates in your trusted certificate list. But what's with this +# <code>--sign</code> option? +# +# === Certificate chains +# +# To answer that question, let's take a look at "certificate chains", a +# concept I mentioned earlier. There are a couple of problems with +# self-signed certificates: first of all, self-signed certificates don't offer +# a whole lot of security. Sure, the certificate says Yukihiro Matsumoto, but +# how do I know it was actually generated and signed by matz himself unless he +# gave me the certificate in person? +# +# The second problem is scalability. Sure, if there are 50 gem authors, then +# I have 50 trusted certificates, no problem. What if there are 500 gem +# authors? 1000? Having to constantly add new trusted certificates is a +# pain, and it actually makes the trust system less secure by encouraging +# RubyGems users to blindly trust new certificates. +# +# Here's where certificate chains come in. A certificate chain establishes an +# arbitrarily long chain of trust between an issuing certificate and a child +# certificate. So instead of trusting certificates on a per-developer basis, +# we use the PKI concept of certificate chains to build a logical hierarchy of +# trust. Here's a hypothetical example of a trust hierarchy based (roughly) +# on geography: +# +# -------------------------- +# | rubygems@rubygems.org | +# -------------------------- +# | +# ----------------------------------- +# | | +# ---------------------------- ----------------------------- +# | seattlerb@seattlerb.org | | dcrubyists@richkilmer.com | +# ---------------------------- ----------------------------- +# | | | | +# --------------- ---------------- ----------- -------------- +# | drbrain | | zenspider | | pabs@dc | | tomcope@dc | +# --------------- ---------------- ----------- -------------- +# +# +# Now, rather than having 4 trusted certificates (one for drbrain, zenspider, +# pabs@dc, and tomecope@dc), a user could actually get by with one +# certificate, the "rubygems@rubygems.org" certificate. +# +# Here's how it works: +# +# I install "rdoc-3.12.gem", a package signed by "drbrain". I've never heard +# of "drbrain", but his certificate has a valid signature from the +# "seattle.rb@seattlerb.org" certificate, which in turn has a valid signature +# from the "rubygems@rubygems.org" certificate. Voila! At this point, it's +# much more reasonable for me to trust a package signed by "drbrain", because +# I can establish a chain to "rubygems@rubygems.org", which I do trust. +# +# === Signing certificates +# +# The <code>--sign</code> option allows all this to happen. A developer +# creates their build certificate with the <code>--build</code> option, then +# has their certificate signed by taking it with them to their next regional +# Ruby meetup (in our hypothetical example), and it's signed there by the +# person holding the regional RubyGems signing certificate, which is signed at +# the next RubyConf by the holder of the top-level RubyGems certificate. At +# each point the issuer runs the same command: +# +# # sign a certificate with the specified key and certificate +# # (note that this modifies client_cert.pem!) +# $ gem cert -K /mnt/floppy/issuer-priv_key.pem -C issuer-pub_cert.pem +# --sign client_cert.pem +# +# Then the holder of issued certificate (in this case, your buddy "drbrain"), +# can start using this signed certificate to sign RubyGems. By the way, in +# order to let everyone else know about his new fancy signed certificate, +# "drbrain" would save his newly signed certificate as +# <code>~/.gem/gem-public_cert.pem</code> +# +# Obviously this RubyGems trust infrastructure doesn't exist yet. Also, in +# the "real world", issuers actually generate the child certificate from a +# certificate request, rather than sign an existing certificate. And our +# hypothetical infrastructure is missing a certificate revocation system. +# These are that can be fixed in the future... +# +# At this point you should know how to do all of these new and interesting +# things: +# +# * build a gem signing key and certificate +# * adjust your security policy +# * modify your trusted certificate list +# * sign a certificate +# +# == Manually verifying signatures +# +# In case you don't trust RubyGems you can verify gem signatures manually: +# +# 1. Fetch and unpack the gem +# +# gem fetch some_signed_gem +# tar -xf some_signed_gem-1.0.gem +# +# 2. Grab the public key from the gemspec +# +# gem spec some_signed_gem-1.0.gem cert_chain | \ +# ruby -rpsych -e 'puts Psych.load($stdin)' > public_key.crt +# +# 3. Generate a SHA1 hash of the data.tar.gz +# +# openssl dgst -sha1 < data.tar.gz > my.hash +# +# 4. Verify the signature +# +# openssl rsautl -verify -inkey public_key.crt -certin \ +# -in data.tar.gz.sig > verified.hash +# +# 5. Compare your hash to the verified hash +# +# diff -s verified.hash my.hash +# +# 6. Repeat 5 and 6 with metadata.gz +# +# == OpenSSL Reference +# +# The .pem files generated by --build and --sign are PEM files. Here's a +# couple of useful OpenSSL commands for manipulating them: +# +# # convert a PEM format X509 certificate into DER format: +# # (note: Windows .cer files are X509 certificates in DER format) +# $ openssl x509 -in input.pem -outform der -out output.der +# +# # print out the certificate in a human-readable format: +# $ openssl x509 -in input.pem -noout -text +# +# And you can do the same thing with the private key file as well: +# +# # convert a PEM format RSA key into DER format: +# $ openssl rsa -in input_key.pem -outform der -out output_key.der +# +# # print out the key in a human readable format: +# $ openssl rsa -in input_key.pem -noout -text +# +# == Bugs/TODO +# +# * There's no way to define a system-wide trust list. +# * custom security policies (from a YAML file, etc) +# * Simple method to generate a signed certificate request +# * Support for OCSP, SCVP, CRLs, or some other form of cert status check +# (list is in order of preference) +# * Support for encrypted private keys +# * Some sort of semi-formal trust hierarchy (see long-winded explanation +# above) +# * Path discovery (for gem certificate chains that don't have a self-signed +# root) -- by the way, since we don't have this, THE ROOT OF THE CERTIFICATE +# CHAIN MUST BE SELF SIGNED if Policy#verify_root is true (and it is for the +# MediumSecurity and HighSecurity policies) +# * Better explanation of X509 naming (ie, we don't have to use email +# addresses) +# * Honor AIA field (see note about OCSP above) +# * Honor extension restrictions +# * Might be better to store the certificate chain as a PKCS#7 or PKCS#12 +# file, instead of an array embedded in the metadata. +# +# == Original author +# +# Paul Duncan <pabs@pablotron.org> +# https://pablotron.org/ + +module Gem::Security + ## + # Gem::Security default exception type + + class Exception < Gem::Exception; end + + ## + # Used internally to select the signing digest from all computed digests + + DIGEST_NAME = "SHA256" # :nodoc: + + ## + # Length of keys created by RSA and DSA keys + + RSA_DSA_KEY_LENGTH = 3072 + + ## + # Default algorithm to use when building a key pair + + DEFAULT_KEY_ALGORITHM = "RSA" + + ## + # Named curve used for Elliptic Curve + + EC_NAME = "secp384r1" + + ## + # Cipher used to encrypt the key pair used to sign gems. + # Must be in the list returned by OpenSSL::Cipher.ciphers + + KEY_CIPHER = OpenSSL::Cipher.new("AES-256-CBC") if defined?(OpenSSL::Cipher) + + ## + # One day in seconds + + ONE_DAY = 86_400 + + ## + # One year in seconds + + ONE_YEAR = ONE_DAY * 365 + + ## + # The default set of extensions are: + # + # * The certificate is not a certificate authority + # * The key for the certificate may be used for key and data encipherment + # and digital signatures + # * The certificate contains a subject key identifier + + EXTENSIONS = { + "basicConstraints" => "CA:FALSE", + "keyUsage" => + "keyEncipherment,dataEncipherment,digitalSignature", + "subjectKeyIdentifier" => "hash", + }.freeze + + def self.alt_name_or_x509_entry(certificate, x509_entry) + alt_name = certificate.extensions.find do |extension| + extension.oid == "#{x509_entry}AltName" + end + + return alt_name.value if alt_name + + certificate.send x509_entry + end + + ## + # Creates an unsigned certificate for +subject+ and +key+. The lifetime of + # the key is from the current time to +age+ which defaults to one year. + # + # The +extensions+ restrict the key to the indicated uses. + + def self.create_cert(subject, key, age = ONE_YEAR, extensions = EXTENSIONS, serial = 1) + cert = OpenSSL::X509::Certificate.new + + cert.public_key = get_public_key(key) + cert.version = 2 + cert.serial = serial + + cert.not_before = Time.now + cert.not_after = Time.now + age + + cert.subject = subject + + ef = OpenSSL::X509::ExtensionFactory.new nil, cert + + cert.extensions = extensions.map do |ext_name, value| + ef.create_extension ext_name, value + end + + cert + end + + ## + # Gets the right public key from a PKey instance + + def self.get_public_key(key) + # Ruby 3.0 (Ruby/OpenSSL 2.2) or later + return OpenSSL::PKey.read(key.public_to_der) if key.respond_to?(:public_to_der) + return key.public_key unless key.is_a?(OpenSSL::PKey::EC) + + ec_key = OpenSSL::PKey::EC.new(key.group.curve_name) + ec_key.public_key = key.public_key + ec_key + end + + ## + # Creates a self-signed certificate with an issuer and subject from +email+, + # a subject alternative name of +email+ and the given +extensions+ for the + # +key+. + + def self.create_cert_email(email, key, age = ONE_YEAR, extensions = EXTENSIONS) + subject = email_to_name email + + extensions = extensions.merge "subjectAltName" => "email:#{email}" + + create_cert_self_signed subject, key, age, extensions + end + + ## + # Creates a self-signed certificate with an issuer and subject of +subject+ + # and the given +extensions+ for the +key+. + + def self.create_cert_self_signed(subject, key, age = ONE_YEAR, extensions = EXTENSIONS, serial = 1) + certificate = create_cert subject, key, age, extensions + + sign certificate, key, certificate, age, extensions, serial + end + + ## + # Creates a new digest instance using the specified +algorithm+. The default + # is SHA256. + + def self.create_digest(algorithm = DIGEST_NAME) + OpenSSL::Digest.new(algorithm) + end + + ## + # Creates a new key pair of the specified +algorithm+. RSA, DSA, and EC + # are supported. + + def self.create_key(algorithm) + if defined?(OpenSSL::PKey) + case algorithm.downcase + when "dsa" + OpenSSL::PKey::DSA.new(RSA_DSA_KEY_LENGTH) + when "rsa" + OpenSSL::PKey::RSA.new(RSA_DSA_KEY_LENGTH) + when "ec" + OpenSSL::PKey::EC.generate(EC_NAME) + else + raise Gem::Security::Exception, + "#{algorithm} algorithm not found. RSA, DSA, and EC algorithms are supported." + end + end + end + + ## + # Turns +email_address+ into an OpenSSL::X509::Name + + def self.email_to_name(email_address) + email_address = email_address.gsub(/[^\w@.-]+/i, "_") + + cn, dcs = email_address.split "@" + + dcs = dcs.split "." + + OpenSSL::X509::Name.new([ + ["CN", cn], + *dcs.map {|dc| ["DC", dc] }, + ]) + end + + ## + # Signs +expired_certificate+ with +private_key+ if the keys match and the + # expired certificate was self-signed. + #-- + # TODO increment serial + + def self.re_sign(expired_certificate, private_key, age = ONE_YEAR, extensions = EXTENSIONS) + raise Gem::Security::Exception, + "incorrect signing key for re-signing " + + expired_certificate.subject.to_s unless + expired_certificate.check_private_key(private_key) + + unless expired_certificate.subject.to_s == + expired_certificate.issuer.to_s + subject = alt_name_or_x509_entry expired_certificate, :subject + issuer = alt_name_or_x509_entry expired_certificate, :issuer + + raise Gem::Security::Exception, + "#{subject} is not self-signed, contact #{issuer} " \ + "to obtain a valid certificate" + end + + serial = expired_certificate.serial + 1 + + create_cert_self_signed(expired_certificate.subject, private_key, age, + extensions, serial) + end + + ## + # Resets the trust directory for verifying gems. + + def self.reset + @trust_dir = nil + end + + ## + # Sign the public key from +certificate+ with the +signing_key+ and + # +signing_cert+, using the Gem::Security::DIGEST_NAME. Uses the + # default certificate validity range and extensions. + # + # Returns the newly signed certificate. + + def self.sign(certificate, signing_key, signing_cert, age = ONE_YEAR, extensions = EXTENSIONS, serial = 1) + signee_subject = certificate.subject + signee_key = certificate.public_key + + alt_name = certificate.extensions.find do |extension| + extension.oid == "subjectAltName" + end + + extensions = extensions.merge "subjectAltName" => alt_name.value if + alt_name + + issuer_alt_name = signing_cert.extensions.find do |extension| + extension.oid == "subjectAltName" + end + + extensions = extensions.merge "issuerAltName" => issuer_alt_name.value if + issuer_alt_name + + signed = create_cert signee_subject, signee_key, age, extensions, serial + signed.issuer = signing_cert.subject + + signed.sign signing_key, Gem::Security::DIGEST_NAME + end + + ## + # Returns a Gem::Security::TrustDir which wraps the directory where trusted + # certificates live. + + def self.trust_dir + return @trust_dir if @trust_dir + + dir = File.join Gem.user_home, ".gem", "trust" + + @trust_dir ||= Gem::Security::TrustDir.new dir + end + + ## + # Enumerates the trusted certificates via Gem::Security::TrustDir. + + def self.trusted_certificates(&block) + trust_dir.each_certificate(&block) + end + + ## + # Writes +pemmable+, which must respond to +to_pem+ to +path+ with the given + # +permissions+. If passed +cipher+ and +passphrase+ those arguments will be + # passed to +to_pem+. + + def self.write(pemmable, path, permissions = 0o600, passphrase = nil, cipher = KEY_CIPHER) + path = File.expand_path path + + File.open path, "wb", permissions do |io| + if passphrase && cipher + io.write pemmable.to_pem cipher, passphrase + else + io.write pemmable.to_pem + end + end + + path + end + + reset +end + +if Gem::HAVE_OPENSSL + require_relative "security/policy" + require_relative "security/policies" + require_relative "security/trust_dir" +end + +require_relative "security/signer" diff --git a/lib/rubygems/security/policies.rb b/lib/rubygems/security/policies.rb new file mode 100644 index 0000000000..41f66043ad --- /dev/null +++ b/lib/rubygems/security/policies.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Gem::Security + ## + # No security policy: all package signature checks are disabled. + + NoSecurity = Policy.new( + "No Security", + verify_data: false, + verify_signer: false, + verify_chain: false, + verify_root: false, + only_trusted: false, + only_signed: false + ) + + ## + # AlmostNo security policy: only verify that the signing certificate is the + # one that actually signed the data. Make no attempt to verify the signing + # certificate chain. + # + # This policy is basically useless. better than nothing, but can still be + # easily spoofed, and is not recommended. + + AlmostNoSecurity = Policy.new( + "Almost No Security", + verify_data: true, + verify_signer: false, + verify_chain: false, + verify_root: false, + only_trusted: false, + only_signed: false + ) + + ## + # Low security policy: only verify that the signing certificate is actually + # the gem signer, and that the signing certificate is valid. + # + # This policy is better than nothing, but can still be easily spoofed, and + # is not recommended. + + LowSecurity = Policy.new( + "Low Security", + verify_data: true, + verify_signer: true, + verify_chain: false, + verify_root: false, + only_trusted: false, + only_signed: false + ) + + ## + # Medium security policy: verify the signing certificate, verify the signing + # certificate chain all the way to the root certificate, and only trust root + # certificates that we have explicitly allowed trust for. + # + # This security policy is reasonable, but it allows unsigned packages, so a + # malicious person could simply delete the package signature and pass the + # gem off as unsigned. + + MediumSecurity = Policy.new( + "Medium Security", + verify_data: true, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: true, + only_signed: false + ) + + ## + # High security policy: only allow signed gems to be installed, verify the + # signing certificate, verify the signing certificate chain all the way to + # the root certificate, and only trust root certificates that we have + # explicitly allowed trust for. + # + # This security policy is significantly more difficult to bypass, and offers + # a reasonable guarantee that the contents of the gem have not been altered. + + HighSecurity = Policy.new( + "High Security", + verify_data: true, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: true, + only_signed: true + ) + + ## + # Policy used to verify a certificate and key when signing a gem + + SigningPolicy = Policy.new( + "Signing Policy", + verify_data: false, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: false, + only_signed: false + ) + + ## + # Hash of configured security policies + + Policies = { + "NoSecurity" => NoSecurity, + "AlmostNoSecurity" => AlmostNoSecurity, + "LowSecurity" => LowSecurity, + "MediumSecurity" => MediumSecurity, + "HighSecurity" => HighSecurity, + # SigningPolicy is not intended for use by `gem -P` so do not list it + }.freeze +end diff --git a/lib/rubygems/security/policy.rb b/lib/rubygems/security/policy.rb new file mode 100644 index 0000000000..128958ab80 --- /dev/null +++ b/lib/rubygems/security/policy.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require_relative "../user_interaction" + +## +# A Gem::Security::Policy object encapsulates the settings for verifying +# signed gem files. This is the base class. You can either declare an +# instance of this or use one of the preset security policies in +# Gem::Security::Policies. + +class Gem::Security::Policy + include Gem::UserInteraction + + attr_reader :name + + attr_accessor :only_signed + attr_accessor :only_trusted + attr_accessor :verify_chain + attr_accessor :verify_data + attr_accessor :verify_root + attr_accessor :verify_signer + + ## + # Create a new Gem::Security::Policy object with the given mode and + # options. + + def initialize(name, policy = {}, opt = {}) + @name = name + + @opt = opt + + # Default to security + @only_signed = true + @only_trusted = true + @verify_chain = true + @verify_data = true + @verify_root = true + @verify_signer = true + + policy.each_pair do |key, val| + case key + when :verify_data then @verify_data = val + when :verify_signer then @verify_signer = val + when :verify_chain then @verify_chain = val + when :verify_root then @verify_root = val + when :only_trusted then @only_trusted = val + when :only_signed then @only_signed = val + end + end + end + + ## + # Verifies each certificate in +chain+ has signed the following certificate + # and is valid for the given +time+. + + def check_chain(chain, time) + raise Gem::Security::Exception, "missing signing chain" unless chain + raise Gem::Security::Exception, "empty signing chain" if chain.empty? + + begin + chain.each_cons 2 do |issuer, cert| + check_cert cert, issuer, time + end + + true + rescue Gem::Security::Exception => e + raise Gem::Security::Exception, "invalid signing chain: #{e.message}" + end + end + + ## + # Verifies that +data+ matches the +signature+ created by +public_key+ and + # the +digest+ algorithm. + + def check_data(public_key, digest, signature, data) + raise Gem::Security::Exception, "invalid signature" unless + public_key.verify digest, signature, data.digest + + true + end + + ## + # Ensures that +signer+ is valid for +time+ and was signed by the +issuer+. + # If the +issuer+ is +nil+ no verification is performed. + + def check_cert(signer, issuer, time) + raise Gem::Security::Exception, "missing signing certificate" unless + signer + + message = "certificate #{signer.subject}" + + if (not_before = signer.not_before) && not_before > time + raise Gem::Security::Exception, + "#{message} not valid before #{not_before}" + end + + if (not_after = signer.not_after) && not_after < time + raise Gem::Security::Exception, "#{message} not valid after #{not_after}" + end + + if issuer && !signer.verify(issuer.public_key) + raise Gem::Security::Exception, + "#{message} was not issued by #{issuer.subject}" + end + + true + end + + ## + # Ensures the public key of +key+ matches the public key in +signer+ + + def check_key(signer, key) + unless signer && key + return true unless @only_signed + + raise Gem::Security::Exception, "missing key or signature" + end + + raise Gem::Security::Exception, + "certificate #{signer.subject} does not match the signing key" unless + signer.check_private_key(key) + + true + end + + ## + # Ensures the root certificate in +chain+ is self-signed and valid for + # +time+. + + def check_root(chain, time) + raise Gem::Security::Exception, "missing signing chain" unless chain + + root = chain.first + + raise Gem::Security::Exception, "missing root certificate" unless root + + raise Gem::Security::Exception, + "root certificate #{root.subject} is not self-signed " \ + "(issuer #{root.issuer})" if + root.issuer != root.subject + + check_cert root, root, time + end + + ## + # Ensures the root of +chain+ has a trusted certificate in Gem::Security.trust_dir and + # the digests of the two certificates match according to +digester+ + + def check_trust(chain, digester, trust_dir) + raise Gem::Security::Exception, "missing signing chain" unless chain + + root = chain.first + + raise Gem::Security::Exception, "missing root certificate" unless root + + path = Gem::Security.trust_dir.cert_path root + + unless File.exist? path + message = "root cert #{root.subject} is not trusted".dup + + message << " (root of signing cert #{chain.last.subject})" if + chain.length > 1 + + raise Gem::Security::Exception, message + end + + save_cert = OpenSSL::X509::Certificate.new File.read path + save_dgst = digester.digest save_cert.public_key.to_pem + + pkey_str = root.public_key.to_pem + cert_dgst = digester.digest pkey_str + + raise Gem::Security::Exception, + "trusted root certificate #{root.subject} checksum " \ + "does not match signing root certificate checksum" unless + save_dgst == cert_dgst + + true + end + + ## + # Extracts the email or subject from +certificate+ + + def subject(certificate) # :nodoc: + certificate.extensions.each do |extension| + next unless extension.oid == "subjectAltName" + + return extension.value + end + + certificate.subject.to_s + end + + def inspect # :nodoc: + format("[Policy: %s - data: %p signer: %p chain: %p root: %p " \ + "signed-only: %p trusted-only: %p]", @name, @verify_chain, @verify_data, @verify_root, @verify_signer, @only_signed, @only_trusted) + end + + ## + # For +full_name+, verifies the certificate +chain+ is valid, the +digests+ + # match the signatures +signatures+ created by the signer depending on the + # +policy+ settings. + # + # If +key+ is given it is used to validate the signing certificate. + + def verify(chain, key = nil, digests = {}, signatures = {}, full_name = "(unknown)") + if signatures.empty? + if @only_signed + raise Gem::Security::Exception, + "unsigned gems are not allowed by the #{name} policy" + elsif digests.empty? + # lack of signatures is irrelevant if there is nothing to check + # against + else + alert_warning "#{full_name} is not signed" + return + end + end + + opt = @opt + digester = Gem::Security.create_digest + trust_dir = opt[:trust_dir] + time = Time.now + + _, signer_digests = digests.find do |_algorithm, file_digests| + file_digests.values.first.name == Gem::Security::DIGEST_NAME + end + + if @verify_data + raise Gem::Security::Exception, "no digests provided (probable bug)" if + signer_digests.nil? || signer_digests.empty? + else + signer_digests = {} + end + + signer = chain.last + + check_key signer, key if key + + check_cert signer, nil, time if @verify_signer + + check_chain chain, time if @verify_chain + + check_root chain, time if @verify_root + + if @only_trusted + check_trust chain, digester, trust_dir + elsif signatures.empty? && digests.empty? + # trust is irrelevant if there's no signatures to verify + else + alert_warning "#{subject signer} is not trusted for #{full_name}" + end + + signatures.each do |file, _| + digest = signer_digests[file] + + raise Gem::Security::Exception, "missing digest for #{file}" unless + digest + end + + signer_digests.each do |file, digest| + signature = signatures[file] + + raise Gem::Security::Exception, "missing signature for #{file}" unless + signature + + check_data signer.public_key, digester, signature, digest if @verify_data + end + + true + end + + ## + # Extracts the certificate chain from the +spec+ and calls #verify to ensure + # the signatures and certificate chain is valid according to the policy.. + + def verify_signatures(spec, digests, signatures) + chain = spec.cert_chain.map do |cert_pem| + OpenSSL::X509::Certificate.new cert_pem + end + + verify chain, nil, digests, signatures, spec.full_name + + true + end + + alias_method :to_s, :name # :nodoc: +end diff --git a/lib/rubygems/security/signer.rb b/lib/rubygems/security/signer.rb new file mode 100644 index 0000000000..eeeeb52906 --- /dev/null +++ b/lib/rubygems/security/signer.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +## +# Basic OpenSSL-based package signing class. + +require_relative "../user_interaction" + +class Gem::Security::Signer + include Gem::UserInteraction + + ## + # The chain of certificates for signing including the signing certificate + + attr_accessor :cert_chain + + ## + # The private key for the signing certificate + + attr_accessor :key + + ## + # The digest algorithm used to create the signature + + attr_reader :digest_algorithm + + ## + # The name of the digest algorithm, used to pull digests out of the hash by + # name. + + attr_reader :digest_name # :nodoc: + + ## + # Gem::Security::Signer options + + attr_reader :options + + DEFAULT_OPTIONS = { + expiration_length_days: 365, + }.freeze + + ## + # Attempts to re-sign an expired cert with a given private key + def self.re_sign_cert(expired_cert, expired_cert_path, private_key) + return unless expired_cert.not_after < Time.now + + expiry = expired_cert.not_after.strftime("%Y%m%d%H%M%S") + expired_cert_file = "#{File.basename(expired_cert_path)}.expired.#{expiry}" + new_expired_cert_path = File.join(Gem.user_home, ".gem", expired_cert_file) + + Gem::Security.write(expired_cert, new_expired_cert_path) + + re_signed_cert = Gem::Security.re_sign( + expired_cert, + private_key, + Gem::Security::ONE_DAY * Gem.configuration.cert_expiration_length_days + ) + + Gem::Security.write(re_signed_cert, expired_cert_path) + + yield(expired_cert_path, new_expired_cert_path) if block_given? + end + + ## + # Creates a new signer with an RSA +key+ or path to a key, and a certificate + # +chain+ containing X509 certificates, encoding certificates or paths to + # certificates. + + def initialize(key, cert_chain, passphrase = nil, options = {}) + @cert_chain = cert_chain + @key = key + @passphrase = passphrase + @options = DEFAULT_OPTIONS.merge(options) + + unless @key + default_key = File.join Gem.default_key_path + @key = default_key if File.exist? default_key + end + + unless @cert_chain + default_cert = File.join Gem.default_cert_path + @cert_chain = [default_cert] if File.exist? default_cert + end + + @digest_name = Gem::Security::DIGEST_NAME + @digest_algorithm = Gem::Security.create_digest(@digest_name) + + if @key && !@key.is_a?(OpenSSL::PKey::PKey) + @key = OpenSSL::PKey.read(File.read(@key), @passphrase) + end + + if @cert_chain + @cert_chain = @cert_chain.compact.map do |cert| + next cert if OpenSSL::X509::Certificate === cert + + cert = File.read cert if File.exist? cert + + OpenSSL::X509::Certificate.new cert + end + + load_cert_chain + end + end + + ## + # Extracts the full name of +cert+. If the certificate has a subjectAltName + # this value is preferred, otherwise the subject is used. + + def extract_name(cert) # :nodoc: + subject_alt_name = cert.extensions.find {|e| e.oid == "subjectAltName" } + + if subject_alt_name + /\Aemail:/ =~ subject_alt_name.value # rubocop:disable Performance/StartWith + + $' || subject_alt_name.value + else + cert.subject + end + end + + ## + # Loads any missing issuers in the cert chain from the trusted certificates. + # + # If the issuer does not exist it is ignored as it will be checked later. + + def load_cert_chain # :nodoc: + return if @cert_chain.empty? + + while @cert_chain.first.issuer.to_s != @cert_chain.first.subject.to_s do + issuer = Gem::Security.trust_dir.issuer_of @cert_chain.first + + break unless issuer # cert chain is verified later + + @cert_chain.unshift issuer + end + end + + ## + # Sign data with given digest algorithm + + def sign(data) + return unless @key + + raise Gem::Security::Exception, "no certs provided" if @cert_chain.empty? + + if @cert_chain.length == 1 && @cert_chain.last.not_after < Time.now + alert("Your certificate has expired, trying to re-sign it...") + + re_sign_key( + expiration_length: (Gem::Security::ONE_DAY * options[:expiration_length_days]) + ) + end + + full_name = extract_name @cert_chain.last + + Gem::Security::SigningPolicy.verify @cert_chain, @key, {}, {}, full_name + + @key.sign @digest_algorithm.new, data + end + + ## + # Attempts to re-sign the private key if the signing certificate is expired. + # + # The key will be re-signed if: + # * The expired certificate is self-signed + # * The expired certificate is saved at ~/.gem/gem-public_cert.pem + # and the private key is saved at ~/.gem/gem-private_key.pem + # * There is no file matching the expiry date at + # ~/.gem/gem-public_cert.pem.expired.%Y%m%d%H%M%S + # + # If the signing certificate can be re-signed the expired certificate will + # be saved as ~/.gem/gem-public_cert.pem.expired.%Y%m%d%H%M%S where the + # expiry time (not after) is used for the timestamp. + + def re_sign_key(expiration_length: Gem::Security::ONE_YEAR) # :nodoc: + old_cert = @cert_chain.last + + disk_cert_path = File.join(Gem.default_cert_path) + disk_cert = begin + File.read(disk_cert_path) + rescue StandardError + nil + end + + disk_key_path = File.join(Gem.default_key_path) + disk_key = begin + OpenSSL::PKey.read(File.read(disk_key_path), @passphrase) + rescue StandardError + nil + end + + return unless disk_key + + if disk_key.to_pem == @key.to_pem && disk_cert == old_cert.to_pem + expiry = old_cert.not_after.strftime("%Y%m%d%H%M%S") + old_cert_file = "gem-public_cert.pem.expired.#{expiry}" + old_cert_path = File.join(Gem.user_home, ".gem", old_cert_file) + + unless File.exist?(old_cert_path) + Gem::Security.write(old_cert, old_cert_path) + + cert = Gem::Security.re_sign(old_cert, @key, expiration_length) + + Gem::Security.write(cert, disk_cert_path) + + alert("Your cert: #{disk_cert_path} has been auto re-signed with the key: #{disk_key_path}") + alert("Your expired cert will be located at: #{old_cert_path}") + + @cert_chain = [cert] + end + end + end +end diff --git a/lib/rubygems/security/trust_dir.rb b/lib/rubygems/security/trust_dir.rb new file mode 100644 index 0000000000..d23d161cfe --- /dev/null +++ b/lib/rubygems/security/trust_dir.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +## +# The TrustDir manages the trusted certificates for gem signature +# verification. + +class Gem::Security::TrustDir + ## + # Default permissions for the trust directory and its contents + + DEFAULT_PERMISSIONS = { + trust_dir: 0o700, + trusted_cert: 0o600, + }.freeze + + ## + # The directory where trusted certificates will be stored. + + attr_reader :dir + + ## + # Creates a new TrustDir using +dir+ where the directory and file + # permissions will be checked according to +permissions+ + + def initialize(dir, permissions = DEFAULT_PERMISSIONS) + @dir = dir + @permissions = permissions + + @digester = Gem::Security.create_digest + end + + ## + # Returns the path to the trusted +certificate+ + + def cert_path(certificate) + name_path certificate.subject + end + + ## + # Enumerates trusted certificates. + + def each_certificate + return enum_for __method__ unless block_given? + + glob = File.join @dir, "*.pem" + + Dir[glob].each do |certificate_file| + certificate = load_certificate certificate_file + + yield certificate, certificate_file + rescue OpenSSL::X509::CertificateError + next # HACK: warn + end + end + + ## + # Returns the issuer certificate of the given +certificate+ if it exists in + # the trust directory. + + def issuer_of(certificate) + path = name_path certificate.issuer + + return unless File.exist? path + + load_certificate path + end + + ## + # Returns the path to the trusted certificate with the given ASN.1 +name+ + + def name_path(name) + digest = @digester.hexdigest name.to_s + + File.join @dir, "cert-#{digest}.pem" + end + + ## + # Loads the given +certificate_file+ + + def load_certificate(certificate_file) + pem = File.read certificate_file + + OpenSSL::X509::Certificate.new pem + end + + ## + # Add a certificate to trusted certificate list. + + def trust_cert(certificate) + verify + + destination = cert_path certificate + + File.open destination, "wb", 0o600 do |io| + io.write certificate.to_pem + io.chmod(@permissions[:trusted_cert]) + end + end + + ## + # Make sure the trust directory exists. If it does exist, make sure it's + # actually a directory. If not, then create it with the appropriate + # permissions. + + def verify + require "fileutils" + if File.exist? @dir + raise Gem::Security::Exception, + "trust directory #{@dir} is not a directory" unless + File.directory? @dir + + FileUtils.chmod 0o700, @dir + else + FileUtils.mkdir_p @dir, mode: @permissions[:trust_dir] + end + end +end diff --git a/lib/rubygems/security_option.rb b/lib/rubygems/security_option.rb new file mode 100644 index 0000000000..3a101fe9db --- /dev/null +++ b/lib/rubygems/security_option.rb @@ -0,0 +1,43 @@ +# 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 "../rubygems" + +# forward-declare + +module Gem::Security # :nodoc: + class Policy # :nodoc: + end +end + +## +# Mixin methods for security option for Gem::Commands + +module Gem::SecurityOption + def add_security_option + Gem::OptionParser.accept Gem::Security::Policy do |value| + require_relative "security" + + raise Gem::OptionParser::InvalidArgument, "OpenSSL not installed" unless + defined?(Gem::Security::HighSecurity) + + policy = Gem::Security::Policies[value] + unless policy + valid = Gem::Security::Policies.keys.sort + raise Gem::OptionParser::InvalidArgument, "#{value} (#{valid.join ", "} are valid)" + end + policy + end + + add_option(:"Install/Update", "-P", "--trust-policy POLICY", + Gem::Security::Policy, + "Specify gem trust policy") do |value, options| + options[:security_policy] = value + end + end +end diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb new file mode 100644 index 0000000000..86717e3e71 --- /dev/null +++ b/lib/rubygems/source.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require_relative "text" +## +# A Source knows how to list and fetch gems from a RubyGems marshal index. +# +# There are other Source subclasses for installed gems, local gems, the +# Compact Index API and so-forth. + +class Gem::Source + include Comparable + include Gem::Text + + FILES = { # :nodoc: + released: "specs", + latest: "latest_specs", + prerelease: "prerelease_specs", + }.freeze + + ## + # The URI this source will fetch gems from. + + attr_reader :uri + + ## + # Creates a new Source which will use the index located at +uri+. + + def initialize(uri) + require_relative "uri" + @uri = Gem::Uri.parse!(uri) + @update_cache = nil + end + + ## + # Sources are ordered by installation preference. + + def <=>(other) + case other + when Gem::Source::Installed, + Gem::Source::Local, + Gem::Source::Lock, + Gem::Source::SpecificFile, + Gem::Source::Git, + Gem::Source::Vendor then + -1 + when Gem::Source then + unless @uri + return 0 unless other.uri + return 1 + end + + return -1 unless other.uri + + # Returning 1 here ensures that when sorting a list of sources, the + # original ordering of sources supplied by the user is preserved. + return 1 unless @uri.to_s == other.uri.to_s + + 0 + end + end + + def ==(other) # :nodoc: + self.class === other && @uri == other.uri + end + + alias_method :eql?, :== # :nodoc: + + ## + # Returns a Set that can fetch specifications from this source. + # + # The set will optionally fetch prereleases if requested. + # + def dependency_resolver_set(prerelease = false) + new_dependency_resolver_set.tap {|set| set.prerelease = prerelease } + end + + def hash # :nodoc: + @uri.hash + end + + ## + # Returns the local directory to write +uri+ to. + + def cache_dir(uri) + # Correct for windows paths + escaped_path = uri.path.sub(%r{^/([a-z]):/}i, '/\\1-/') + + File.join Gem.spec_cache_dir, "#{uri.host}%#{uri.port}", File.dirname(escaped_path) + end + + ## + # Returns true when it is possible and safe to update the cache directory. + + def update_cache? + return @update_cache unless @update_cache.nil? + @update_cache = + begin + File.stat(Gem.user_home).uid == Process.uid + rescue Errno::ENOENT + false + end + end + + ## + # Fetches a specification for the given Gem::NameTuple. + + def fetch_spec(name_tuple) + fetcher = Gem::RemoteFetcher.fetcher + + spec_file_name = name_tuple.spec_name + + source_uri = enforce_trailing_slash(uri) + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}" + + cache_dir = cache_dir source_uri + + local_spec = File.join cache_dir, spec_file_name + + if File.exist? local_spec + spec = Gem.read_binary local_spec + Gem.load_safe_marshal + spec = begin + Gem::SafeMarshal.safe_load(spec) + rescue StandardError + nil + end + return spec if spec + end + + source_uri.path << ".rz" + + spec = fetcher.fetch_path source_uri + spec = Gem::Util.inflate spec + + if update_cache? + require "fileutils" + FileUtils.mkdir_p cache_dir + + File.open local_spec, "wb" do |io| + io.write spec + end + end + + Gem.load_safe_marshal + # TODO: Investigate setting Gem::Specification#loaded_from to a URI + Gem::SafeMarshal.safe_load spec + end + + ## + # Loads +type+ kind of specs fetching from +@uri+ if the on-disk cache is + # out of date. + # + # +type+ is one of the following: + # + # :released => Return the list of all released specs + # :latest => Return the list of only the highest version of each gem + # :prerelease => Return the list of all prerelease only specs + # + + def load_specs(type) + file = FILES[type] + fetcher = Gem::RemoteFetcher.fetcher + file_name = "#{file}.#{Gem.marshal_version}" + spec_path = enforce_trailing_slash(uri) + "#{file_name}.gz" + cache_dir = cache_dir spec_path + local_file = File.join(cache_dir, file_name) + retried = false + + if update_cache? + require "fileutils" + FileUtils.mkdir_p cache_dir + end + + spec_dump = fetcher.cache_update_path spec_path, local_file, update_cache? + + Gem.load_safe_marshal + begin + Gem::NameTuple.from_list Gem::SafeMarshal.safe_load(spec_dump) + rescue ArgumentError + if update_cache? && !retried + FileUtils.rm local_file + retried = true + retry + else + raise Gem::Exception.new("Invalid spec cache file in #{local_file}") + end + end + end + + ## + # Downloads +spec+ and writes it to +dir+. See also + # Gem::RemoteFetcher#download. + + def download(spec, dir = Dir.pwd) + fetcher = Gem::RemoteFetcher.fetcher + fetcher.download spec, uri.to_s, dir + end + + def pretty_print(q) # :nodoc: + q.object_group(self) do + q.group 2, "[Remote:", "]" do + q.breakable + q.text @uri.to_s + + if api = uri + q.breakable + q.text "API URI: " + q.text api.to_s + end + end + end + end + + def typo_squatting?(host, distance_threshold = 4) + return if @uri.host.nil? + levenshtein_distance(@uri.host, host).between? 1, distance_threshold + end + + private + + def new_dependency_resolver_set + return Gem::Resolver::IndexSet.new self if uri.scheme == "file" + + fetch_uri = if uri.host == "rubygems.org" + index_uri = uri.dup + index_uri.host = "index.rubygems.org" + index_uri + else + uri + end + + bundler_api_uri = enforce_trailing_slash(fetch_uri) + "versions" + + begin + fetcher = Gem::RemoteFetcher.fetcher + response = fetcher.fetch_path bundler_api_uri, nil, true + rescue Gem::RemoteFetcher::FetchError + Gem::Resolver::IndexSet.new self + else + Gem::Resolver::APISet.new response.uri + "./info/" + end + end + + def enforce_trailing_slash(uri) + uri.merge(uri.path.gsub(%r{/+$}, "") + "/") + end +end + +require_relative "source/git" +require_relative "source/installed" +require_relative "source/specific_file" +require_relative "source/local" +require_relative "source/lock" +require_relative "source/vendor" diff --git a/lib/rubygems/source/git.rb b/lib/rubygems/source/git.rb new file mode 100644 index 0000000000..baf2f9dd4c --- /dev/null +++ b/lib/rubygems/source/git.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +## +# A git gem for use in a gem dependencies file. +# +# Example: +# +# source = +# Gem::Source::Git.new 'rake', 'git@example:rake.git', 'rake-10.1.0', false +# +# source.specs + +class Gem::Source::Git < Gem::Source + ## + # The name of the gem created by this git gem. + + attr_reader :name + + ## + # The commit reference used for checking out this git gem. + + attr_reader :reference + + ## + # When false the cache for this repository will not be updated. + + attr_accessor :remote + + ## + # The git repository this gem is sourced from. + + attr_reader :repository + + ## + # The directory for cache and git gem installation + + attr_accessor :root_dir + + ## + # Does this repository need submodules checked out too? + + attr_reader :need_submodules + + ## + # Creates a new git gem source for a gems from loaded from +repository+ at + # the given +reference+. The +name+ is only used to track the repository + # back to a gem dependencies file, it has no real significance as a git + # repository may contain multiple gems. If +submodules+ is true, submodules + # will be checked out when the gem is installed. + + def initialize(name, repository, reference, submodules = false) + require_relative "../uri" + @uri = Gem::Uri.parse(repository) + @name = name + @repository = repository + @reference = reference || "HEAD" + @need_submodules = submodules + + @remote = true + @root_dir = Gem.dir + end + + def <=>(other) + case other + when Gem::Source::Git then + 0 + when Gem::Source::Vendor, + Gem::Source::Lock then + -1 + when Gem::Source then + 1 + end + end + + def ==(other) # :nodoc: + super && + @name == other.name && + @repository == other.repository && + @reference == other.reference && + @need_submodules == other.need_submodules + end + + def git_command + ENV.fetch("git", "git") + end + + ## + # Checks out the files for the repository into the install_dir. + + def checkout # :nodoc: + cache + + return false unless File.exist? repo_cache_dir + + unless File.exist? install_dir + system git_command, "clone", "--quiet", "--no-checkout", + repo_cache_dir, install_dir + end + + Dir.chdir install_dir do + system git_command, "fetch", "--quiet", "--force", "--tags", install_dir + + success = system git_command, "reset", "--quiet", "--hard", rev_parse + + if @need_submodules + require "open3" + _, status = Open3.capture2e(git_command, "submodule", "update", "--quiet", "--init", "--recursive") + + success &&= status.success? + end + + success + end + end + + ## + # Creates a local cache repository for the git gem. + + def cache # :nodoc: + return unless @remote + + if File.exist? repo_cache_dir + Dir.chdir repo_cache_dir do + system git_command, "fetch", "--quiet", "--force", "--tags", + @repository, "refs/heads/*:refs/heads/*" + end + else + system git_command, "clone", "--quiet", "--bare", "--no-hardlinks", + @repository, repo_cache_dir + end + end + + ## + # Directory where git gems get unpacked and so-forth. + + def base_dir # :nodoc: + File.join @root_dir, "bundler" + end + + ## + # A short reference for use in git gem directories + + def dir_shortref # :nodoc: + rev_parse[0..11] + end + + ## + # Nothing to download for git gems + + def download(full_spec, path) # :nodoc: + end + + ## + # The directory where the git gem will be installed. + + def install_dir # :nodoc: + return unless File.exist? repo_cache_dir + + File.join base_dir, "gems", "#{@name}-#{dir_shortref}" + end + + def pretty_print(q) # :nodoc: + q.object_group(self) do + q.group 2, "[Git: ", "]" do + q.breakable + q.text @repository + + q.breakable + q.text @reference + end + end + end + + ## + # The directory where the git gem's repository will be cached. + + def repo_cache_dir # :nodoc: + File.join @root_dir, "cache", "bundler", "git", "#{@name}-#{uri_hash}" + end + + ## + # Converts the git reference for the repository into a commit hash. + + def rev_parse # :nodoc: + hash = nil + + Dir.chdir repo_cache_dir do + hash = Gem::Util.popen(git_command, "rev-parse", @reference).strip + end + + raise Gem::Exception, + "unable to find reference #{@reference} in #{@repository}" unless + $?.success? + + hash + end + + ## + # Loads all gemspecs in the repository + + def specs + checkout + + return [] unless install_dir + + Dir.chdir install_dir do + Dir["{,*,*/*}.gemspec"].filter_map do |spec_file| + directory = File.dirname spec_file + file = File.basename spec_file + + Dir.chdir directory do + spec = Gem::Specification.load file + if spec + spec.base_dir = base_dir + + spec.extension_dir = + File.join base_dir, "extensions", Gem::Platform.local.to_s, + Gem.extension_api_version, "#{name}-#{dir_shortref}" + + spec.full_gem_path = File.dirname spec.loaded_from if spec + end + spec + end + end + end + end + + ## + # A hash for the git gem based on the git repository Gem::URI. + + def uri_hash # :nodoc: + require_relative "../openssl" + + normalized = + if @repository.match?(%r{^\w+://(\w+@)?}) + uri = Gem::URI(@repository).normalize.to_s.sub %r{/$},"" + uri.sub(/\A(\w+)/) { $1.downcase } + else + @repository + end + + OpenSSL::Digest::SHA1.hexdigest normalized + end +end diff --git a/lib/rubygems/source/installed.rb b/lib/rubygems/source/installed.rb new file mode 100644 index 0000000000..f5c96fee51 --- /dev/null +++ b/lib/rubygems/source/installed.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +## +# Represents an installed gem. This is used for dependency resolution. + +class Gem::Source::Installed < Gem::Source + def initialize # :nodoc: + @uri = nil + end + + ## + # Installed sources sort before all other sources + + def <=>(other) + case other + when Gem::Source::Git, + Gem::Source::Lock, + Gem::Source::Vendor then + -1 + when Gem::Source::Installed then + 0 + when Gem::Source then + 1 + end + end + + ## + # We don't need to download an installed gem + + def download(spec, path) + nil + end + + def pretty_print(q) # :nodoc: + q.object_group(self) do + q.text "[Installed]" + end + end +end diff --git a/lib/rubygems/source/local.rb b/lib/rubygems/source/local.rb new file mode 100644 index 0000000000..4bef31a265 --- /dev/null +++ b/lib/rubygems/source/local.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +## +# The local source finds gems in the current directory for fulfilling +# dependencies. + +class Gem::Source::Local < Gem::Source + def initialize # :nodoc: + @specs = nil + @api_uri = nil + @uri = nil + @load_specs_names = {} + end + + ## + # Local sorts before Gem::Source and after Gem::Source::Installed + + def <=>(other) + case other + when Gem::Source::Installed, + Gem::Source::Lock then + -1 + when Gem::Source::Local then + 0 + when Gem::Source then + 1 + end + end + + def inspect # :nodoc: + keys = @specs ? @specs.keys.sort : "NOT LOADED" + format("#<%s specs: %p>", self.class, keys) + end + + def load_specs(type) # :nodoc: + @load_specs_names[type] ||= begin + names = [] + + @specs = {} + + Dir["*.gem"].each do |file| + pkg = Gem::Package.new(file) + spec = pkg.spec + rescue SystemCallError, Gem::Package::FormatError + # ignore + else + tup = spec.name_tuple + @specs[tup] = [File.expand_path(file), pkg] + + case type + when :released + unless pkg.spec.version.prerelease? + names << pkg.spec.name_tuple + end + when :prerelease + if pkg.spec.version.prerelease? + names << pkg.spec.name_tuple + end + when :latest + tup = pkg.spec.name_tuple + + cur = names.find {|x| x.name == tup.name } + if !cur + names << tup + elsif cur.version < tup.version + names.delete cur + names << tup + end + else + names << pkg.spec.name_tuple + end + end + + names + end + end + + def find_gem(gem_name, version = Gem::Requirement.default, prerelease = false) # :nodoc: + find_all_gems(gem_name, version, prerelease).max_by(&:version) + end + + def find_all_gems(gem_name, version = Gem::Requirement.default, prerelease = false) # :nodoc: + load_specs :complete + + found = [] + + @specs.each do |n, data| + next unless n.name == gem_name + s = data[1].spec + + if version.satisfied_by?(s.version) + if prerelease + found << s + elsif !s.version.prerelease? || version.prerelease? + found << s + end + end + end + + found + end + + def fetch_spec(name) # :nodoc: + load_specs :complete + + if data = @specs[name] + data.last.spec + else + raise Gem::Exception, "Unable to find spec for #{name.inspect}" + end + end + + def download(spec, cache_dir = nil) # :nodoc: + load_specs :complete + + @specs.each do |_name, data| + return data[0] if data[1].spec == spec + end + + raise Gem::Exception, "Unable to find file for '#{spec.full_name}'" + end + + def pretty_print(q) # :nodoc: + q.object_group(self) do + q.group 2, "[Local gems:", "]" do + q.breakable + if @specs + q.seplist @specs.keys do |v| + q.text v.full_name + end + end + end + end + end +end diff --git a/lib/rubygems/source/lock.rb b/lib/rubygems/source/lock.rb new file mode 100644 index 0000000000..70849210bd --- /dev/null +++ b/lib/rubygems/source/lock.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +## +# A Lock source wraps an installed gem's source and sorts before other sources +# during dependency resolution. This allows RubyGems to prefer gems from +# dependency lock files. + +class Gem::Source::Lock < Gem::Source + ## + # The wrapped Gem::Source + + attr_reader :wrapped + + ## + # Creates a new Lock source that wraps +source+ and moves it earlier in the + # sort list. + + def initialize(source) + @wrapped = source + end + + def <=>(other) # :nodoc: + case other + when Gem::Source::Lock then + @wrapped <=> other.wrapped + when Gem::Source then + 1 + end + end + + def ==(other) # :nodoc: + (self <=> other) == 0 + end + + def hash # :nodoc: + @wrapped.hash ^ 3 + end + + ## + # Delegates to the wrapped source's fetch_spec method. + + def fetch_spec(name_tuple) + @wrapped.fetch_spec name_tuple + end + + def uri # :nodoc: + @wrapped.uri + end +end diff --git a/lib/rubygems/source/specific_file.rb b/lib/rubygems/source/specific_file.rb new file mode 100644 index 0000000000..dde1d48a21 --- /dev/null +++ b/lib/rubygems/source/specific_file.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +## +# A source representing a single .gem file. This is used for installation of +# local gems. + +class Gem::Source::SpecificFile < Gem::Source + ## + # The path to the gem for this specific file. + + attr_reader :path + + ## + # Creates a new SpecificFile for the gem in +file+ + + def initialize(file) + @uri = nil + @path = ::File.expand_path(file) + + @package = Gem::Package.new @path + @spec = @package.spec + @name = @spec.name_tuple + end + + ## + # The Gem::Specification extracted from this .gem. + + attr_reader :spec + + def load_specs(*a) # :nodoc: + [@name] + end + + def fetch_spec(name) # :nodoc: + return @spec if name == @name + raise Gem::Exception, "Unable to find '#{name}'" + end + + def download(spec, dir = nil) # :nodoc: + return @path if spec == @spec + raise Gem::Exception, "Unable to download '#{spec.full_name}'" + end + + def pretty_print(q) # :nodoc: + q.object_group(self) do + q.group 2, "[SpecificFile:", "]" do + q.breakable + q.text @path + end + end + end + + ## + # Orders this source against +other+. + # + # If +other+ is a SpecificFile from a different gem name +nil+ is returned. + # + # If +other+ is a SpecificFile from the same gem name the versions are + # compared using Gem::Version#<=> + # + # Otherwise Gem::Source#<=> is used. + + def <=>(other) + case other + when Gem::Source::SpecificFile then + return nil if @spec.name != other.spec.name + + @spec.version <=> other.spec.version + else + super + end + end +end diff --git a/lib/rubygems/source/vendor.rb b/lib/rubygems/source/vendor.rb new file mode 100644 index 0000000000..44ef614441 --- /dev/null +++ b/lib/rubygems/source/vendor.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +## +# This represents a vendored source that is similar to an installed gem. + +class Gem::Source::Vendor < Gem::Source::Installed + ## + # Creates a new Vendor source for a gem that was unpacked at +path+. + + def initialize(path) + @uri = path + end + + def <=>(other) + case other + when Gem::Source::Lock then + -1 + when Gem::Source::Vendor then + 0 + when Gem::Source then + 1 + end + end +end diff --git a/lib/rubygems/source_list.rb b/lib/rubygems/source_list.rb new file mode 100644 index 0000000000..19bf4595c4 --- /dev/null +++ b/lib/rubygems/source_list.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +## +# The SourceList represents the sources rubygems has been configured to use. +# A source may be created from an array of sources: +# +# Gem::SourceList.from %w[https://rubygems.example https://internal.example] +# +# Or by adding them: +# +# sources = Gem::SourceList.new +# sources << 'https://rubygems.example' +# +# The most common way to get a SourceList is Gem.sources. + +class Gem::SourceList + include Enumerable + + ## + # Creates a new SourceList + + def initialize + @sources = [] + end + + ## + # The sources in this list + + attr_reader :sources + + ## + # Creates a new SourceList from an array of sources. + + def self.from(ary) + list = new + + list.replace ary + + list + end + + def initialize_copy(other) # :nodoc: + @sources = @sources.dup + end + + ## + # Appends +obj+ to the source list which may be a Gem::Source, Gem::URI or URI + # String. + + def <<(obj) + src = case obj + when Gem::Source + obj + else + Gem::Source.new(obj) + end + + @sources << src unless @sources.include?(src) + src + end + + ## + # Prepends +obj+ to the beginning of the source list which may be a Gem::Source, Gem::URI or URI + # Moves +obj+ to the beginning of the list if already present. + # String. + + def prepend(obj) + src = case obj + when Gem::Source + obj + else + Gem::Source.new(obj) + end + + @sources.delete(src) if @sources.include?(src) + @sources.unshift(src) + src + end + + ## + # Appends +obj+ to the end of the source list, moving it if already present. + # +obj+ may be a Gem::Source, Gem::URI or URI String. + # Moves +obj+ to the end of the list if already present. + + def append(obj) + src = case obj + when Gem::Source + obj + else + Gem::Source.new(obj) + end + + @sources.delete(src) if @sources.include?(src) + @sources << src + src + end + + ## + # Replaces this SourceList with the sources in +other+ See #<< for + # acceptable items in +other+. + + def replace(other) + clear + + other.each do |x| + self << x + end + + self + end + + ## + # Removes all sources from the SourceList. + + def clear + @sources.clear + end + + ## + # Yields each source URI in the list. + + def each + @sources.each {|s| yield s.uri.to_s } + end + + ## + # Yields each source in the list. + + def each_source(&b) + @sources.each(&b) + end + + ## + # Returns true if there are no sources in this SourceList. + + def empty? + @sources.empty? + end + + def ==(other) # :nodoc: + to_a == other + end + + ## + # Returns an Array of source URI Strings. + + def to_a + @sources.map {|x| x.uri.to_s } + end + + alias_method :to_ary, :to_a + + ## + # Returns the first source in the list. + + def first + @sources.first + end + + ## + # Returns true if this source list includes +other+ which may be a + # Gem::Source or a source URI. + + def include?(other) + if other.is_a? Gem::Source + @sources.include? other + else + @sources.find {|x| x.uri.to_s == other.to_s } + end + end + + ## + # Deletes +source+ from the source list which may be a Gem::Source or a URI. + + def delete(source) + if source.is_a? Gem::Source + @sources.delete source + else + @sources.delete_if {|x| x.uri.to_s == source.to_s } + end + end +end diff --git a/lib/rubygems/spec_fetcher.rb b/lib/rubygems/spec_fetcher.rb new file mode 100644 index 0000000000..835dedf948 --- /dev/null +++ b/lib/rubygems/spec_fetcher.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require_relative "remote_fetcher" +require_relative "user_interaction" +require_relative "errors" +require_relative "text" +require_relative "name_tuple" + +## +# SpecFetcher handles metadata updates from remote gem repositories. + +class Gem::SpecFetcher + include Gem::UserInteraction + include Gem::Text + + ## + # Cache of latest specs + + attr_reader :latest_specs # :nodoc: + + ## + # Sources for this SpecFetcher + + attr_reader :sources # :nodoc: + + ## + # Cache of all released specs + + attr_reader :specs # :nodoc: + + ## + # Cache of prerelease specs + + attr_reader :prerelease_specs # :nodoc: + + @fetcher = nil + + ## + # Default fetcher instance. Use this instead of ::new to reduce object + # allocation. + + def self.fetcher + @fetcher ||= new + end + + def self.fetcher=(fetcher) # :nodoc: + @fetcher = fetcher + end + + ## + # Creates a new SpecFetcher. Ordinarily you want to use the default fetcher + # from Gem::SpecFetcher::fetcher which uses the Gem.sources. + # + # If you need to retrieve specifications from a different +source+, you can + # send it as an argument. + + def initialize(sources = nil) + @sources = sources || Gem.sources + + @update_cache = + begin + File.stat(Gem.user_home).uid == Process.uid + rescue Errno::EACCES, Errno::ENOENT + false + end + + @specs = {} + @latest_specs = {} + @prerelease_specs = {} + + @caches = { + latest: @latest_specs, + prerelease: @prerelease_specs, + released: @specs, + } + + @fetcher = Gem::RemoteFetcher.fetcher + end + + ## + # + # Find and fetch gem name tuples that match +dependency+. + # + # If +matching_platform+ is false, gems for all platforms are returned. + + def search_for_dependency(dependency, matching_platform = true) + found = {} + + rejected_specs = {} + + list, errors = available_specs(dependency.identity) + + list.each do |source, specs| + if dependency.name.is_a?(String) && specs.respond_to?(:bsearch) + start_index = (0...specs.length).bsearch {|i| specs[i].name >= dependency.name } + end_index = (0...specs.length).bsearch {|i| specs[i].name > dependency.name } + specs = specs[start_index...end_index] if start_index && end_index + end + + found[source] = specs.select do |tup| + if dependency.match?(tup) + if matching_platform && !Gem::Platform.match_gem?(tup.platform, tup.name) + pm = ( + rejected_specs[dependency] ||= \ + Gem::PlatformMismatch.new(tup.name, tup.version)) + pm.add_platform tup.platform + false + else + true + end + end + end + end + + errors += rejected_specs.values + + tuples = [] + + found.each do |source, specs| + specs.each do |s| + tuples << [s, source] + end + end + + tuples = tuples.sort_by {|x| x[0].version } + + [tuples, errors] + end + + ## + # Return all gem name tuples who's names match +obj+ + + def detect(type = :complete) + tuples = [] + + list, _ = available_specs(type) + list.each do |source, specs| + specs.each do |tup| + if yield(tup) + tuples << [tup, source] + end + end + end + + tuples + end + + ## + # Find and fetch specs that match +dependency+. + # + # If +matching_platform+ is false, gems for all platforms are returned. + + def spec_for_dependency(dependency, matching_platform = true) + tuples, errors = search_for_dependency(dependency, matching_platform) + + specs = [] + tuples.each do |tup, source| + spec = source.fetch_spec(tup) + rescue Gem::RemoteFetcher::FetchError => e + errors << Gem::SourceFetchProblem.new(source, e) + else + specs << [spec, source] + end + + [specs, errors] + end + + ## + # Suggests gems based on the supplied +gem_name+. Returns an array of + # alternative gem names. + + def suggest_gems_from_name(gem_name, type = :latest, num_results = 5) + gem_name = gem_name.downcase.tr("_-", "") + + # All results for 3-character-or-shorter (minus hyphens/underscores) gem + # names get rejected, so we just return an empty array immediately instead. + return [] if gem_name.length <= 3 + + max = gem_name.size / 2 + names = available_specs(type).first.values.flatten(1) + + min_length = gem_name.length - max + max_length = gem_name.length + max + + gem_name_with_postfix = "#{gem_name}ruby" + gem_name_with_prefix = "ruby#{gem_name}" + + matches = names.filter_map do |n| + len = n.name.length + # If the gem doesn't support the current platform, bail early. + next unless n.match_platform? + + # If the length is min_length or shorter, we've done `max` deletions. + # This would be rejected later, so we skip it for performance. + next if len <= min_length + + # The candidate name, normalized the same as gem_name. + normalized_name = n.name.downcase + normalized_name.tr!("_-", "") + + # If the gem is "{NAME}-ruby" and "ruby-{NAME}", we want to return it. + # But we already removed hyphens, so we check "{NAME}ruby" and "ruby{NAME}". + next [n.name, 0] if normalized_name == gem_name_with_postfix + next [n.name, 0] if normalized_name == gem_name_with_prefix + + # If the length is max_length or longer, we've done `max` insertions. + # This would be rejected later, so we skip it for performance. + next if len >= max_length + + # If we found an exact match (after stripping underscores and hyphens), + # that's our most likely candidate. + # Return it immediately, and skip the rest of the loop. + return [n.name] if normalized_name == gem_name + + distance = levenshtein_distance gem_name, normalized_name + + # Skip current candidate, if the edit distance is greater than allowed. + next if distance >= max + + # If all else fails, return the name and the calculated distance. + [n.name, distance] + end + + matches = if matches.empty? && type != :prerelease + suggest_gems_from_name gem_name, :prerelease + else + matches.uniq.sort_by {|_name, dist| dist } + end + + matches.map {|name, _dist| name }.uniq.first(num_results) + end + + ## + # Returns a list of gems available for each source in Gem::sources. + # + # +type+ can be one of 3 values: + # :released => Return the list of all released specs + # :complete => Return the list of all specs + # :latest => Return the list of only the highest version of each gem + # :prerelease => Return the list of all prerelease only specs + # + + def available_specs(type) + errors = [] + list = {} + + @sources.each_source do |source| + names = case type + when :latest + tuples_for source, :latest + when :released + tuples_for source, :released + when :complete + names = + tuples_for(source, :prerelease, true) + + tuples_for(source, :released) + + names.sort + when :abs_latest + names = + tuples_for(source, :prerelease, true) + + tuples_for(source, :latest) + + names.sort + when :prerelease + tuples_for(source, :prerelease) + else + raise Gem::Exception, "Unknown type - :#{type}" + end + rescue Gem::RemoteFetcher::FetchError => e + errors << Gem::SourceFetchProblem.new(source, e) + else + list[source] = names + end + + [list, errors] + end + + ## + # Retrieves NameTuples from +source+ of the given +type+ (:prerelease, + # etc.). If +gracefully_ignore+ is true, errors are ignored. + + def tuples_for(source, type, gracefully_ignore = false) # :nodoc: + @caches[type][source.uri] ||= + source.load_specs(type).sort_by(&:name) + rescue Gem::RemoteFetcher::FetchError + raise unless gracefully_ignore + [] + end +end diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb new file mode 100644 index 0000000000..51729d755b --- /dev/null +++ b/lib/rubygems/specification.rb @@ -0,0 +1,2604 @@ +# 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 "basic_specification" +require_relative "stub_specification" +require_relative "platform" +require_relative "specification_record" + +require "rbconfig" + +## +# The Specification class contains the information for a gem. Typically +# defined in a .gemspec file or a Rakefile, and looks like this: +# +# Gem::Specification.new do |s| +# s.name = 'example' +# s.version = '0.1.0' +# s.licenses = ['MIT'] +# s.summary = "This is an example!" +# s.description = "Much longer explanation of the example!" +# s.authors = ["Ruby Coder"] +# s.email = 'rubycoder@example.com' +# s.files = ["lib/example.rb"] +# s.homepage = 'https://rubygems.org/gems/example' +# s.metadata = { "source_code_uri" => "https://github.com/example/example" } +# end +# +# Starting in RubyGems 2.0, a Specification can hold arbitrary +# metadata. See #metadata for restrictions on the format and size of metadata +# items you may add to a specification. +# +# Specifications must be deterministic, as in the example above. For instance, +# you cannot define attributes conditionally: +# +# # INVALID: do not do this. +# unless RUBY_ENGINE == "jruby" +# s.extensions << "ext/example/extconf.rb" +# end +# + +class Gem::Specification < Gem::BasicSpecification + # REFACTOR: Consider breaking out this version stuff into a separate + # module. There's enough special stuff around it that it may justify + # a separate class. + + ## + # The version number of a specification that does not specify one + # (i.e. RubyGems 0.7 or earlier). + + NONEXISTENT_SPECIFICATION_VERSION = -1 + + ## + # The specification version applied to any new Specification instances + # created. This should be bumped whenever something in the spec format + # changes. + # + # Specification Version History: + # + # spec ruby + # ver ver yyyy-mm-dd description + # -1 <0.8.0 pre-spec-version-history + # 1 0.8.0 2004-08-01 Deprecated "test_suite_file" for "test_files" + # "test_file=x" is a shortcut for "test_files=[x]" + # 2 0.9.5 2007-10-01 Added "required_rubygems_version" + # Now forward-compatible with future versions + # 3 1.3.2 2009-01-03 Added Fixnum validation to specification_version + # 4 1.9.0 2011-06-07 Added metadata + #-- + # When updating this number, be sure to also update #to_ruby. + # + # NOTE RubyGems < 1.2 cannot load specification versions > 2. + + CURRENT_SPECIFICATION_VERSION = 4 # :nodoc: + + ## + # An informal list of changes to the specification. The highest-valued + # key should be equal to the CURRENT_SPECIFICATION_VERSION. + + SPECIFICATION_VERSION_HISTORY = { # :nodoc: + -1 => ["(RubyGems versions up to and including 0.7 did not have versioned specifications)"], + 1 => [ + 'Deprecated "test_suite_file" in favor of the new, but equivalent, "test_files"', + '"test_file=x" is a shortcut for "test_files=[x]"', + ], + 2 => [ + 'Added "required_rubygems_version"', + "Now forward-compatible with future versions", + ], + 3 => [ + "Added Fixnum validation to the specification_version", + ], + 4 => [ + "Added sandboxed freeform metadata to the specification version.", + ], + }.freeze + + MARSHAL_FIELDS = { # :nodoc: + -1 => 16, + 1 => 16, + 2 => 16, + 3 => 17, + 4 => 18, + }.freeze + + today = Time.now.utc + TODAY = Time.utc(today.year, today.month, today.day) # :nodoc: + + @load_cache = {} # :nodoc: + @load_cache_mutex = Thread::Mutex.new + + VALID_NAME_PATTERN = /\A[a-zA-Z0-9\.\-\_]+\z/ # :nodoc: + + # :startdoc: + + ## + # List of attribute names: [:name, :version, ...] + + @@required_attributes = [:rubygems_version, + :specification_version, + :name, + :version, + :date, + :summary, + :require_paths] + + ## + # Map of attribute names to default values. + + @@default_value = { + authors: [], + autorequire: nil, + bindir: "bin", + cert_chain: [], + date: nil, + dependencies: [], + description: nil, + email: nil, + executables: [], + extensions: [], + extra_rdoc_files: [], + files: [], + homepage: nil, + licenses: [], + metadata: {}, + name: nil, + platform: Gem::Platform::RUBY, + post_install_message: nil, + rdoc_options: [], + require_paths: ["lib"], + required_ruby_version: Gem::Requirement.default, + required_rubygems_version: Gem::Requirement.default, + requirements: [], + rubygems_version: Gem::VERSION, + signing_key: nil, + specification_version: CURRENT_SPECIFICATION_VERSION, + summary: nil, + test_files: [], + version: nil, + }.freeze + + # rubocop:disable Style/MutableConstant + INITIALIZE_CODE_FOR_DEFAULTS = {} # :nodoc: + # rubocop:enable Style/MutableConstant + + @@default_value.each do |k,v| + INITIALIZE_CODE_FOR_DEFAULTS[k] = case v + when [], {}, true, false, nil, Numeric, Symbol + v.inspect + when String + v.dump + else + "default_value(:#{k}).dup" + end + end + + @@attributes = @@default_value.keys.sort_by(&:to_s) + @@array_attributes = @@default_value.select {|_k,v| v.is_a?(Array) }.keys + @@nil_attributes, @@non_nil_attributes = @@default_value.keys.partition do |k| + @@default_value[k].nil? + end + + # Sentinel object to represent "not found" stubs + NOT_FOUND = Struct.new(:to_spec, :this).new # :nodoc: + deprecate_constant :NOT_FOUND + + # Tracking removed method calls to warn users during build time. + REMOVED_METHODS = [:rubyforge_project=, :mark_version].freeze # :nodoc: + def removed_method_calls + @removed_method_calls ||= [] + end + + ###################################################################### + # :section: Required gemspec attributes + + ## + # This gem's name. + # + # Usage: + # + # spec.name = 'rake' + + attr_accessor :name + + ## + # This gem's version. + # + # The version string can contain numbers and periods, such as +1.0.0+. + # A gem is a 'prerelease' gem if the version has a letter in it, such as + # +1.0.0.pre+. + # + # Usage: + # + # spec.version = '0.4.1' + + attr_reader :version + + ## + # A short summary of this gem's description. Displayed in <tt>gem list -d</tt>. + # + # The #description should be more detailed than the summary. + # + # Usage: + # + # spec.summary = "This is a small summary of my gem" + + attr_reader :summary + + ## + # Files included in this gem. You cannot append to this accessor, you must + # assign to it. + # + # Only add files you can require to this list, not directories, etc. + # + # Directories are automatically stripped from this list when building a gem, + # other non-files cause an error. + # + # Usage: + # + # require 'rake' + # spec.files = FileList['lib/**/*.rb', + # 'bin/*', + # '[A-Z]*'].to_a + # + # # or without Rake... + # spec.files = Dir['lib/**/*.rb'] + Dir['bin/*'] + # spec.files += Dir['[A-Z]*'] + # spec.files.reject! { |fn| fn.include? "CVS" } + + def files + # DO NOT CHANGE TO ||= ! This is not a normal accessor. (yes, it sucks) + # DOC: Why isn't it normal? Why does it suck? How can we fix this? + @files = [@files, + @test_files, + add_bindir(@executables), + @extra_rdoc_files, + @extensions].flatten.compact.uniq.sort + end + + ## + # A list of authors for this gem. + # + # Alternatively, a single author can be specified by assigning a string to + # +spec.author+ + # + # Usage: + # + # spec.authors = ['John Jones', 'Mary Smith'] + + def authors=(value) + @authors = Array(value).flatten.grep(String) + end + + ###################################################################### + # :section: Recommended gemspec attributes + + ## + # The version of Ruby required by this gem + # + # Usage: + # + # spec.required_ruby_version = '>= 2.7.0' + + attr_reader :required_ruby_version + + ## + # A long description of this gem + # + # The description should be more detailed than the summary but not + # excessively long. A few paragraphs is a recommended length with no + # examples or formatting. + # + # Usage: + # + # spec.description = <<~EOF + # Rake is a Make-like program implemented in Ruby. Tasks and + # dependencies are specified in standard Ruby syntax. + # EOF + + attr_reader :description + + ## + # A contact email address (or addresses) for this gem + # + # Usage: + # + # spec.email = 'john.jones@example.com' + # spec.email = ['jack@example.com', 'jill@example.com'] + + attr_accessor :email + + ## + # The URL of this gem's home page + # + # Usage: + # + # spec.homepage = 'https://github.com/ruby/rake' + + attr_accessor :homepage + + ## + # The license for this gem. + # + # The license must be no more than 64 characters. + # + # This should just be the name of your license. The full text of the license + # should be inside of the gem (at the top level) when you build it. + # + # The simplest way is to specify the standard SPDX ID + # https://spdx.org/licenses/ for the license. + # Ideally, you should pick one that is OSI (Open Source Initiative) + # https://opensource.org/licenses/ approved. + # + # The most commonly used OSI-approved licenses are MIT and Apache-2.0. + # GitHub also provides a license picker at https://choosealicense.com/. + # + # You can also use a custom license file along with your gemspec and specify + # a LicenseRef-<idstring>, where idstring is the name of the file containing + # the license text. + # + # You should specify a license for your gem so that people know how they are + # permitted to use it and any restrictions you're placing on it. Not + # specifying a license means all rights are reserved; others have no right + # to use the code for any purpose. + # + # You can set multiple licenses with #licenses= + # + # Usage: + # spec.license = 'MIT' + + def license=(o) + self.licenses = [o] + end + + ## + # The license(s) for the library. + # + # Each license must be a short name, no more than 64 characters. + # + # This should just be the name of your license. The full + # text of the license should be inside of the gem when you build it. + # + # See #license= for more discussion + # + # Usage: + # spec.licenses = ['MIT', 'GPL-2.0'] + + def licenses=(licenses) + @licenses = Array licenses + end + + ## + # The metadata holds extra data for this gem that may be useful to other + # consumers and is settable by gem authors. + # + # Metadata items have the following restrictions: + # + # * The metadata must be a Hash object + # * All keys and values must be Strings + # * Keys can be a maximum of 128 bytes and values can be a maximum of 1024 + # bytes + # * All strings must be UTF-8, no binary data is allowed + # + # You can use metadata to specify links to your gem's homepage, codebase, + # documentation, wiki, mailing list, issue tracker and changelog. + # + # s.metadata = { + # "bug_tracker_uri" => "https://example.com/user/bestgemever/issues", + # "changelog_uri" => "https://example.com/user/bestgemever/CHANGELOG.md", + # "documentation_uri" => "https://www.example.info/gems/bestgemever/0.0.1", + # "homepage_uri" => "https://bestgemever.example.io", + # "mailing_list_uri" => "https://groups.example.com/bestgemever", + # "source_code_uri" => "https://example.com/user/bestgemever", + # "wiki_uri" => "https://example.com/user/bestgemever/wiki", + # "funding_uri" => "https://example.com/donate" + # } + # + # These links will be used on your gem's page on rubygems.org and must pass + # validation against following regex. + # + # %r{\Ahttps?:\/\/([^\s:@]+:[^\s:@]*@)?[A-Za-z\d\-]+(\.[A-Za-z\d\-]+)+\.?(:\d{1,5})?([\/?]\S*)?\z} + + attr_accessor :metadata + + ###################################################################### + # :section: Optional gemspec attributes + + ## + # Singular (alternative) writer for #authors + # + # Usage: + # + # spec.author = 'John Jones' + + def author=(o) + self.authors = [o] + end + + ## + # The path in the gem for executable scripts. Usually 'exe' + # + # Usage: + # + # spec.bindir = 'exe' + + attr_accessor :bindir + + ## + # The certificate chain used to sign this gem. See Gem::Security for + # details. + + attr_accessor :cert_chain + + ## + # A message that gets displayed after the gem is installed. + # + # Usage: + # + # spec.post_install_message = "Thanks for installing!" + + attr_accessor :post_install_message + + ## + # The platform this gem runs on. + # + # This is usually Gem::Platform::RUBY or Gem::Platform::CURRENT. + # + # Most gems contain pure Ruby code; they should simply leave the default + # value in place. Some gems contain C (or other) code to be compiled into a + # Ruby "extension". The gem should leave the default value in place unless + # the code will only compile on a certain type of system. Some gems consist + # of pre-compiled code ("binary gems"). It's especially important that they + # set the platform attribute appropriately. A shortcut is to set the + # platform to Gem::Platform::CURRENT, which will cause the gem builder to set + # the platform to the appropriate value for the system on which the build is + # being performed. + # + # If this attribute is set to a non-default value, it will be included in + # the filename of the gem when it is built such as: + # nokogiri-1.6.0-x86-mingw32.gem + # + # Usage: + # + # spec.platform = Gem::Platform.local + + def platform=(platform) + @original_platform = platform + + case platform + when Gem::Platform::CURRENT then + @new_platform = Gem::Platform.local + @original_platform = @new_platform.to_s + + when Gem::Platform then + @new_platform = platform + + # legacy constants + when nil, Gem::Platform::RUBY then + @new_platform = Gem::Platform::RUBY + when "mswin32" then # was Gem::Platform::WIN32 + @new_platform = Gem::Platform.new "x86-mswin32" + when "i586-linux" then # was Gem::Platform::LINUX_586 + @new_platform = Gem::Platform.new "x86-linux" + when "powerpc-darwin" then # was Gem::Platform::DARWIN + @new_platform = Gem::Platform.new "ppc-darwin" + else + @new_platform = Gem::Platform.new platform + end + + @platform = @new_platform.to_s + end + + ## + # Paths in the gem to add to <code>$LOAD_PATH</code> when this gem is + # activated. + #-- + # See also #require_paths + #++ + # If you have an extension you do not need to add <code>"ext"</code> to the + # require path, the extension build process will copy the extension files + # into "lib" for you. + # + # The default value is <code>"lib"</code> + # + # Usage: + # + # # If all library files are in the root directory... + # spec.require_paths = ['.'] + + def require_paths=(val) + @require_paths = Array(val) + end + + ## + # The RubyGems version required by this gem + + attr_reader :required_rubygems_version + + ## + # The key used to sign this gem. See Gem::Security for details. + + attr_accessor :signing_key + + ## + # Adds a development dependency named +gem+ with +requirements+ to this + # gem. + # + # Usage: + # + # spec.add_development_dependency 'example', '~> 1.1', '>= 1.1.4' + # + # Development dependencies aren't installed by default and aren't + # activated when a gem is required. + + def add_development_dependency(gem, *requirements) + add_dependency_with_type(gem, :development, requirements) + end + + ## + # Adds a runtime dependency named +gem+ with +requirements+ to this gem. + # + # Usage: + # + # spec.add_dependency 'example', '~> 1.1', '>= 1.1.4' + + def add_dependency(gem, *requirements) + if requirements.uniq.size != requirements.size + warn "WARNING: duplicated #{gem} dependency #{requirements}" + end + + add_dependency_with_type(gem, :runtime, requirements) + end + + ## + # Executables included in the gem. + # + # For example, the rake gem has rake as an executable. You don't specify the + # full path (as in bin/rake); all application-style files are expected to be + # found in bindir. These files must be executable Ruby files. Files that + # use bash or other interpreters will not work. + # + # Executables included may only be ruby scripts, not scripts for other + # languages or compiled binaries. + # + # Usage: + # + # spec.executables << 'rake' + + def executables + @executables ||= [] + end + + ## + # Extensions to build when installing the gem, specifically the paths to + # extconf.rb-style files used to compile extensions. + # + # These files will be run when the gem is installed, causing the C (or + # whatever) code to be compiled on the user's machine. + # + # Usage: + # + # spec.extensions << 'ext/rmagic/extconf.rb' + # + # See Gem::Ext::Builder for information about writing extensions for gems. + + def extensions + @extensions ||= [] + end + + ## + # Extra files to add to RDoc such as README or doc/examples.txt + # + # When the user elects to generate the RDoc documentation for a gem (typically + # at install time), all the library files are sent to RDoc for processing. + # This option allows you to have some non-code files included for a more + # complete set of documentation. + # + # Usage: + # + # spec.extra_rdoc_files = ['README', 'doc/user-guide.txt'] + + def extra_rdoc_files + @extra_rdoc_files ||= [] + end + + ## + # The version of RubyGems that installed this gem. Returns + # <code>Gem::Version.new(0)</code> for gems installed by versions earlier + # than RubyGems 2.2.0. + + def installed_by_version # :nodoc: + @installed_by_version ||= Gem::Version.new(0) + end + + ## + # Sets the version of RubyGems that installed this gem. See also + # #installed_by_version. + + def installed_by_version=(version) # :nodoc: + @installed_by_version = Gem::Version.new version + end + + ## + # Specifies the rdoc options to be used when generating API documentation. + # + # Usage: + # + # spec.rdoc_options << '--title' << 'Rake -- Ruby Make' << + # '--main' << 'README' << + # '--line-numbers' + + def rdoc_options + @rdoc_options ||= [] + end + + LATEST_RUBY_WITHOUT_PATCH_VERSIONS = Gem::Version.new("2.1") + + ## + # The version of Ruby required by this gem. The ruby version can be + # specified to the patch-level: + # + # $ ruby -v -e 'p Gem.ruby_version' + # ruby 2.0.0p247 (2013-06-27 revision 41674) [x86_64-darwin12.4.0] + # #<Gem::Version "2.0.0.247"> + # + # Prereleases can also be specified. + # + # Usage: + # + # # This gem will work with 1.8.6 or greater... + # spec.required_ruby_version = '>= 1.8.6' + # + # # Only with final releases of major version 2 where minor version is at least 3 + # spec.required_ruby_version = '~> 2.3' + # + # # Only prereleases or final releases after 2.6.0.preview2 + # spec.required_ruby_version = '> 2.6.0.preview2' + # + # # This gem will work with 2.3.0 or greater, including major version 3, but lesser than 4.0.0 + # spec.required_ruby_version = '>= 2.3', '< 4' + + def required_ruby_version=(req) + @required_ruby_version = Gem::Requirement.create req + + @required_ruby_version.requirements.map! do |op, v| + if v >= LATEST_RUBY_WITHOUT_PATCH_VERSIONS && v.release.segments.size == 4 + [op == "~>" ? "=" : op, Gem::Version.new(v.segments.tap {|s| s.delete_at(3) }.join("."))] + else + [op, v] + end + end + end + + ## + # The RubyGems version required by this gem + + def required_rubygems_version=(req) + @required_rubygems_version = Gem::Requirement.create req + end + + ## + # Lists the external (to RubyGems) requirements that must be met for this gem + # to work. It's simply information for the user. + # + # Usage: + # + # spec.requirements << 'libmagick, v6.0' + # spec.requirements << 'A good graphics card' + + def requirements + @requirements ||= [] + end + + ## + # A collection of unit test files. They will be loaded as unit tests when + # the user requests a gem to be unit tested. + # + # Usage: + # spec.test_files = Dir.glob('test/tc_*.rb') + # spec.test_files = ['tests/test-suite.rb'] + + def test_files=(files) # :nodoc: + @test_files = Array files + end + + ###################################################################### + # :section: Read-only attributes + + ## + # The version of RubyGems used to create this gem. + + attr_accessor :rubygems_version + + ## + # The path where this gem installs its extensions. + + def extensions_dir + @extensions_dir ||= super + end + + ###################################################################### + # :section: Specification internals + + ## + # True when this gemspec has been activated. This attribute is not persisted. + + attr_accessor :activated + + alias_method :activated?, :activated + + ## + # Autorequire was used by old RubyGems to automatically require a file. + # + # Deprecated: It is neither supported nor functional. + + attr_accessor :autorequire # :nodoc: + + ## + # Allows deinstallation of gems with legacy platforms. + + attr_writer :original_platform # :nodoc: + + ## + # The Gem::Specification version of this gemspec. + # + # Do not set this, it is set automatically when the gem is packaged. + + attr_accessor :specification_version + + def self._all # :nodoc: + specification_record.all + end + + def self.clear_load_cache # :nodoc: + @load_cache_mutex.synchronize do + @load_cache.clear + end + end + private_class_method :clear_load_cache + + def self.gem_path # :nodoc: + Gem.path + end + private_class_method :gem_path + + def self.each_gemspec(dirs) # :nodoc: + dirs.each do |dir| + Gem::Util.glob_files_in_dir("*.gemspec", dir).each do |path| + yield path + end + end + end + + def self.gemspec_stubs_in(dir, pattern) # :nodoc: + Gem::Util.glob_files_in_dir(pattern, dir).map {|path| yield path }.select(&:valid?) + end + + def self.each_spec(dirs) # :nodoc: + each_gemspec(dirs) do |path| + spec = self.load path + yield spec if spec + end + end + + ## + # Returns a Gem::StubSpecification for every installed gem + + def self.stubs + specification_record.stubs + end + + ## + # Returns a Gem::StubSpecification for default gems + + def self.default_stubs(pattern = "*.gemspec") + base_dir = Gem.default_dir + gems_dir = File.join base_dir, "gems" + gemspec_stubs_in(Gem.default_specifications_dir, pattern) do |path| + Gem::StubSpecification.default_gemspec_stub(path, base_dir, gems_dir) + end + end + + ## + # Returns a Gem::StubSpecification for installed gem named +name+ + # only returns stubs that match Gem.platforms + + def self.stubs_for(name) + specification_record.stubs_for(name) + end + + ## + # Finds stub specifications matching a pattern from the standard locations, + # optionally filtering out specs not matching the current platform + # + def self.stubs_for_pattern(pattern, match_platform = true) # :nodoc: + specification_record.stubs_for_pattern(pattern, match_platform) + end + + def self._resort!(specs) # :nodoc: + specs.sort! do |a, b| + names = a.name <=> b.name + next names if names.nonzero? + versions = b.version <=> a.version + next versions if versions.nonzero? + platforms = Gem::Platform.sort_priority(b.platform) <=> Gem::Platform.sort_priority(a.platform) + next platforms if platforms.nonzero? + default_gem = a.default_gem_priority <=> b.default_gem_priority + next default_gem if default_gem.nonzero? + a.base_dir_priority(gem_path) <=> b.base_dir_priority(gem_path) + end + end + + ## + # Loads the default specifications. It should be called only once. + + def self.load_defaults + each_spec([Gem.default_specifications_dir]) do |spec| + # #load returns nil if the spec is bad, so we just ignore + # it at this stage + Gem.register_default_spec(spec) + end + end + + ## + # Adds +spec+ to the known specifications, keeping the collection + # properly sorted. + + def self.add_spec(spec) + specification_record.add_spec(spec) + end + + ## + # Removes +spec+ from the known specs. + + def self.remove_spec(spec) + specification_record.remove_spec(spec) + end + + ## + # Returns all specifications. This method is discouraged from use. + # You probably want to use one of the Enumerable methods instead. + + def self.all + warn "NOTE: Specification.all called from #{caller(1, 1).first}" unless + Gem::Deprecate.skip + _all + end + + ## + # Sets the known specs to +specs+. + + def self.all=(specs) + specification_record.all = specs + end + + ## + # Return full names of all specs in sorted order. + + def self.all_names + specification_record.all_names + end + + ## + # Return the list of all array-oriented instance variables. + #-- + # Not sure why we need to use so much stupid reflection in here... + + def self.array_attributes + @@array_attributes.dup + end + + ## + # Return the list of all instance variables. + #-- + # Not sure why we need to use so much stupid reflection in here... + + def self.attribute_names + @@attributes.dup + end + + ## + # Return the directories that Specification uses to find specs. + + def self.dirs + @@dirs ||= Gem::SpecificationRecord.dirs_from(gem_path) + end + + ## + # Set the directories that Specification uses to find specs. Setting + # this resets the list of known specs. + + def self.dirs=(dirs) + reset + + @@dirs = Gem::SpecificationRecord.dirs_from(Array(dirs)) + end + + extend Enumerable + + ## + # Enumerate every known spec. See ::dirs= and ::add_spec to set the list of + # specs. + + def self.each(&block) + specification_record.each(&block) + end + + ## + # Returns every spec that matches +name+ and optional +requirements+. + + def self.find_all_by_name(name, *requirements) + specification_record.find_all_by_name(name, *requirements) + end + + ## + # Returns every spec that has the given +full_name+ + + def self.find_all_by_full_name(full_name) + stubs.select {|s| s.full_name == full_name }.map(&:to_spec) + end + + ## + # Find the best specification matching a +name+ and +requirements+. Raises + # if the dependency doesn't resolve to a valid specification. + + def self.find_by_name(name, *requirements) + requirements = Gem::Requirement.default if requirements.empty? + + Gem::Dependency.new(name, *requirements).to_spec + end + + ## + # Find the best specification matching a +full_name+. + def self.find_by_full_name(full_name) + stubs.find {|s| s.full_name == full_name }&.to_spec + end + + ## + # Return the best specification that contains the file matching +path+. + + def self.find_by_path(path) + specification_record.find_by_path(path) + end + + ## + # Return the best specification that contains the file matching +path+ + # amongst the specs that are not loaded. This method is different than + # +find_inactive_by_path+ as it will filter out loaded specs by their name. + + def self.find_unloaded_by_path(path) + specification_record.find_unloaded_by_path(path) + end + + ## + # Return the best specification that contains the file matching +path+ + # amongst the specs that are not activated. + + def self.find_inactive_by_path(path) + specification_record.find_inactive_by_path(path) + end + + ## + # Return the best specification that contains the file matching +path+, among + # those already activated. + + def self.find_active_stub_by_path(path) + specification_record.find_active_stub_by_path(path) + end + + ## + # Return currently unresolved specs that contain the file matching +path+. + + def self.find_in_unresolved(path) + unresolved_specs.find_all {|spec| spec.contains_requirable_file? path } + end + + ## + # Search through all unresolved deps and sub-dependencies and return + # specs that contain the file matching +path+. + + def self.find_in_unresolved_tree(path) + unresolved_specs.each do |spec| + spec.traverse do |_from_spec, _dep, to_spec, trail| + if to_spec.has_conflicts? || to_spec.conflicts_when_loaded_with?(trail) + :next + else + return trail.reverse if to_spec.contains_requirable_file? path + end + end + end + + [] + end + + def self.unresolved_specs + unresolved_deps.values.flat_map(&:to_specs) + end + private_class_method :unresolved_specs + + ## + # Special loader for YAML files. When a Specification object is loaded + # from a YAML file, it bypasses the normal Ruby object initialization + # routine (#initialize). This method makes up for that and deals with + # gems of different ages. + # + # +input+ can be anything that YAML.load() accepts: String or IO. + + def self.from_yaml(input) + Gem.load_yaml + + input = normalize_yaml_input input + spec = Gem::SafeYAML.safe_load input + + if spec && spec.class == FalseClass + raise Gem::EndOfYAMLException + end + + unless Gem::Specification === spec + raise Gem::Exception, "YAML data doesn't evaluate to gem specification" + end + + spec.specification_version ||= NONEXISTENT_SPECIFICATION_VERSION + spec.reset_nil_attributes_to_default + spec.flatten_require_paths + + spec + end + + ## + # Return the latest specs, optionally including prerelease specs if + # +prerelease+ is true. + + def self.latest_specs(prerelease = false) + specification_record.latest_specs(prerelease) + end + + ## + # Return the latest installed spec for gem +name+. + + def self.latest_spec_for(name) + specification_record.latest_spec_for(name) + end + + def self._latest_specs(specs, prerelease = false) # :nodoc: + result = {} + + specs.reverse_each do |spec| + unless prerelease + next if spec.version.prerelease? + end + + result[spec.name] = spec + end + + result.flat_map(&:last).sort_by(&:name) + end + + ## + # Loads Ruby format gemspec from +file+. + + def self.load(file) + return unless file + + spec = @load_cache_mutex.synchronize { @load_cache[file] } + return spec if spec + + return unless File.file?(file) + + code = Gem.open_file(file, "r:UTF-8:-", &:read) + + begin + spec = eval code, binding, file + + if Gem::Specification === spec + spec.loaded_from = File.expand_path file.to_s + @load_cache_mutex.synchronize do + prev = @load_cache[file] + if prev + spec = prev + else + @load_cache[file] = spec + end + end + return spec + end + + warn "[#{file}] isn't a Gem::Specification (#{spec.class} instead)." + rescue SignalException, SystemExit + raise + rescue SyntaxError, StandardError => e + warn "Invalid gemspec in [#{file}]: #{e}" + end + + nil + end + + ## + # Specification attributes that must be non-nil + + def self.non_nil_attributes + @@non_nil_attributes.dup + end + + ## + # Make sure the YAML specification is properly formatted with dashes + + def self.normalize_yaml_input(input) + result = input.respond_to?(:read) ? input.read : input + result = "--- " + result unless result.start_with?("--- ") + result = result.dup + result.gsub!(/ !!null \n/, " \n") + # date: 2011-04-26 00:00:00.000000000Z + # date: 2011-04-26 00:00:00.000000000 Z + result.gsub!(/^(date: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+?)Z/, '\1 Z') + result + end + + ## + # Return a list of all outdated local gem names. This method is HEAVY + # as it must go fetch specifications from the server. + # + # Use outdated_and_latest_version if you wish to retrieve the latest remote + # version as well. + + def self.outdated + outdated_and_latest_version.map {|local, _| local.name } + end + + ## + # Enumerates the outdated local gems yielding the local specification and + # the latest remote version. + # + # This method may take some time to return as it must check each local gem + # against the server's index. + + def self.outdated_and_latest_version + return enum_for __method__ unless block_given? + + # TODO: maybe we should switch to rubygems' version service? + fetcher = Gem::SpecFetcher.fetcher + + latest_specs(true).each do |local_spec| + dependency = + Gem::Dependency.new local_spec.name, ">= #{local_spec.version}" + + remotes, = fetcher.search_for_dependency dependency + remotes = remotes.map {|n, _| n.version } + + latest_remote = remotes.sort.last + + yield [local_spec, latest_remote] if + latest_remote && local_spec.version < latest_remote + end + + nil + end + + ## + # Is +name+ a required attribute? + + def self.required_attribute?(name) + @@required_attributes.include? name.to_sym + end + + ## + # Required specification attributes + + def self.required_attributes + @@required_attributes.dup + end + + ## + # Reset the list of known specs, running pre and post reset hooks + # registered in Gem. + + def self.reset + @@dirs = nil + Gem.pre_reset_hooks.each(&:call) + @specification_record = nil + clear_load_cache + + unless unresolved_deps.empty? + unresolved = unresolved_deps.filter_map do |name, dep| + matching_versions = find_all_by_name(name) + next if dep.latest_version? && matching_versions.any?(&:default_gem?) + + [dep, matching_versions.uniq(&:full_name)] + end.to_h + + unless unresolved.empty? + warn "WARN: Unresolved or ambiguous specs during Gem::Specification.reset:" + unresolved.each do |dep, versions| + warn " #{dep}" + + unless versions.empty? + warn " Available/installed versions of this gem:" + versions.each {|s| warn " - #{s.version}" } + end + end + warn "WARN: Clearing out unresolved specs. Try 'gem cleanup <gem>'" + warn "Please report a bug if this causes problems." + end + + unresolved_deps.clear + end + Gem.post_reset_hooks.each(&:call) + end + + ## + # Keeps track of all currently known specifications + + def self.specification_record + @specification_record ||= Gem::SpecificationRecord.new(dirs) + end + + # DOC: This method needs documented or nodoc'd + def self.unresolved_deps + @unresolved_deps ||= Hash.new {|h, n| h[n] = Gem::Dependency.new n } + end + + ## + # Load custom marshal format, re-initializing defaults as needed + + def self._load(str) + Gem.load_yaml + Gem.load_safe_marshal + + yaml_set = false + retry_count = 0 + + array = begin + Gem::SafeMarshal.safe_load str + rescue ArgumentError => e + # Avoid an infinite retry loop when the argument error has nothing to do + # with the classes not being defined. + # 1 retry each allowed in case all 3 of + # - YAML + # - YAML::Syck::DefaultKey + # - YAML::PrivateType + # need to be defined + raise if retry_count >= 3 + + # + # Some very old marshaled specs included references to `YAML::PrivateType` + # and `YAML::Syck::DefaultKey` constants due to bugs in the old emitter + # that generated them. Workaround the issue by defining the necessary + # constants and retrying. + # + message = e.message + raise unless message.include?("YAML::") + + unless Object.const_defined?(:YAML) + Object.const_set "YAML", Module.new + yaml_set = true + end + + if message.include?("YAML::Syck::") + YAML.const_set "Syck", YAML unless YAML.const_defined?(:Syck) + + YAML::Syck.const_set "DefaultKey", Class.new if message.include?("YAML::Syck::DefaultKey") && !YAML::Syck.const_defined?(:DefaultKey) + elsif message.include?("YAML::PrivateType") && !YAML.const_defined?(:PrivateType) + YAML.const_set "PrivateType", Class.new { attr_accessor :type_id, :value } + end + + retry_count += 1 + retry + ensure + Object.__send__(:remove_const, "YAML") if yaml_set + end + + spec = Gem::Specification.new + spec.instance_variable_set :@specification_version, array[1] + + current_version = CURRENT_SPECIFICATION_VERSION + + field_count = if spec.specification_version > current_version + spec.instance_variable_set :@specification_version, + current_version + MARSHAL_FIELDS[current_version] + else + MARSHAL_FIELDS[spec.specification_version] + end + + if array.size < field_count + raise TypeError, "invalid Gem::Specification format #{array.inspect}" + end + + spec.instance_variable_set :@rubygems_version, array[0] + # spec version + spec.instance_variable_set :@name, array[2] + spec.instance_variable_set :@version, array[3] + spec.date = array[4] + spec.instance_variable_set :@summary, array[5] + spec.instance_variable_set :@required_ruby_version, array[6] + spec.instance_variable_set :@required_rubygems_version, array[7] + spec.platform = array[8] + spec.instance_variable_set :@dependencies, array[9] + # offset due to rubyforge_project removal + spec.instance_variable_set :@email, array[11] + spec.instance_variable_set :@authors, array[12] + spec.instance_variable_set :@description, array[13] + spec.instance_variable_set :@homepage, array[14] + # offset due to has_rdoc removal + spec.instance_variable_set :@licenses, array[17] + spec.instance_variable_set :@metadata, array[18] + spec.instance_variable_set :@loaded, false + spec.instance_variable_set :@activated, false + + spec + end + + def <=>(other) # :nodoc: + sort_obj <=> other.sort_obj + end + + def ==(other) # :nodoc: + self.class === other && + name == other.name && + version == other.version && + platform == other.platform + end + + ## + # Dump only crucial instance variables. + #-- + # MAINTAIN ORDER! + # (down with the man) + + def _dump(limit) + Marshal.dump [ + @rubygems_version, + @specification_version, + @name, + @version, + date, + @summary, + @required_ruby_version, + @required_rubygems_version, + @original_platform, + @dependencies, + "", # rubyforge_project + @email, + @authors, + @description, + @homepage, + true, # has_rdoc + @new_platform, + @licenses, + @metadata, + ] + end + + ## + # Activate this spec, registering it as a loaded spec and adding + # it's lib paths to $LOAD_PATH. Returns true if the spec was + # activated, false if it was previously activated. Freaks out if + # there are conflicts upon activation. + + def activate + other = Gem.loaded_specs[name] + if other + check_version_conflict other + return false + end + + raise_if_conflicts + + activate_dependencies + add_self_to_load_path + + Gem.loaded_specs[name] = self + @activated = true + @loaded = true + + true + end + + ## + # Activate all unambiguously resolved runtime dependencies of this + # spec. Add any ambiguous dependencies to the unresolved list to be + # resolved later, as needed. + + def activate_dependencies + unresolved = Gem::Specification.unresolved_deps + + runtime_dependencies.each do |spec_dep| + if loaded = Gem.loaded_specs[spec_dep.name] + next if spec_dep.matches_spec? loaded + + msg = "can't satisfy '#{spec_dep}', already activated '#{loaded.full_name}'" + e = Gem::LoadError.new msg + e.name = spec_dep.name + + raise e + end + + specs = spec_dep.matching_specs(true).uniq(&:full_name) + + if specs.size == 0 + raise Gem::MissingSpecError.new(spec_dep.name, spec_dep.requirement, "at: #{spec_file}") + elsif specs.size == 1 + specs.first.activate + else + name = spec_dep.name + unresolved[name] = unresolved[name].merge spec_dep + end + end + + unresolved.delete self.name + end + + ## + # Abbreviate the spec for downloading. Abbreviated specs are only used for + # searching, downloading and related activities and do not need deployment + # specific information (e.g. list of files). So we abbreviate the spec, + # making it much smaller for quicker downloads. + + def abbreviate + self.files = [] + self.test_files = [] + self.rdoc_options = [] + self.extra_rdoc_files = [] + self.cert_chain = [] + end + + ## + # Sanitize the descriptive fields in the spec. Sometimes non-ASCII + # characters will garble the site index. Non-ASCII characters will + # be replaced by their XML entity equivalent. + + def sanitize + self.summary = sanitize_string(summary) + self.description = sanitize_string(description) + self.post_install_message = sanitize_string(post_install_message) + self.authors = authors.collect {|a| sanitize_string(a) } + end + + ## + # Sanitize a single string. + + def sanitize_string(string) + return string unless string + + # HACK: the #to_s is in here because RSpec has an Array of Arrays of + # Strings for authors. Need a way to disallow bad values on gemspec + # generation. (Probably won't happen.) + string.to_s + end + + ## + # Returns an array with bindir attached to each executable in the + # +executables+ list + + def add_bindir(executables) + return nil if executables.nil? + + if @bindir + Array(executables).map {|e| File.join(@bindir, e) } + else + executables + end + rescue StandardError + nil + end + + ## + # Adds a dependency on gem +dependency+ with type +type+ that requires + # +requirements+. Valid types are currently <tt>:runtime</tt> and + # <tt>:development</tt>. + + def add_dependency_with_type(dependency, type, requirements) + requirements = if requirements.empty? + Gem::Requirement.default + else + requirements.flatten + end + + unless dependency.respond_to?(:name) && + dependency.respond_to?(:requirement) + dependency = Gem::Dependency.new(dependency.to_s, requirements, type) + end + + dependencies << dependency + end + + private :add_dependency_with_type + + alias_method :add_runtime_dependency, :add_dependency + + ## + # Adds this spec's require paths to LOAD_PATH, in the proper location. + + def add_self_to_load_path + return if default_gem? + + paths = full_require_paths + + Gem.add_to_load_path(*paths) + end + + ## + # Singular reader for #authors. Returns the first author in the list + + def author + (val = authors) && val.first + end + + ## + # The list of author names who wrote this gem. + # + # spec.authors = ['Chad Fowler', 'Jim Weirich', 'Rich Kilmer'] + + def authors + @authors ||= [] + end + + ## + # Returns the full path to installed gem's bin directory. + # + # NOTE: do not confuse this with +bindir+, which is just 'bin', not + # a full path. + + def bin_dir + @bin_dir ||= File.join gem_dir, bindir + end + + ## + # Returns the full path to an executable named +name+ in this gem. + + def bin_file(name) + File.join bin_dir, name + end + + ## + # Returns the build_args used to install the gem + + def build_args + if File.exist? build_info_file + build_info = File.readlines build_info_file + build_info = build_info.map(&:strip) + build_info.delete "" + build_info + else + [] + end + end + + ## + # Builds extensions for this platform if the gem has extensions listed and + # the gem.build_complete file is missing. + + def build_extensions # :nodoc: + return if extensions.empty? + return if default_gem? + # we need to fresh build when same name and version of default gems + return if self.class.find_by_full_name(full_name)&.default_gem? + return if File.exist? gem_build_complete_path + return unless File.writable?(base_dir) + return unless File.exist?(File.join(base_dir, "extensions")) + + begin + # We need to require things in $LOAD_PATH without looking for the + # extension we are about to build. + unresolved_deps = Gem::Specification.unresolved_deps.dup + Gem::Specification.unresolved_deps.clear + + require_relative "config_file" + require_relative "ext" + require_relative "user_interaction" + + ui = Gem::SilentUI.new + Gem::DefaultUserInteraction.use_ui ui do + builder = Gem::Ext::Builder.new self + builder.build_extensions + end + ensure + ui&.close + Gem::Specification.unresolved_deps.replace unresolved_deps + end + end + + ## + # Returns the full path to the build info directory + + def build_info_dir + File.join base_dir, "build_info" + end + + ## + # Returns the full path to the file containing the build + # information generated when the gem was installed + + def build_info_file + File.join build_info_dir, "#{full_name}.info" + end + + ## + # Returns the full path to the cache directory containing this + # spec's cached gem. + + def cache_dir + File.join base_dir, "cache" + end + + ## + # Returns the full path to the cached gem for this spec. + + def cache_file + File.join cache_dir, "#{full_name}.gem" + end + + ## + # Return any possible conflicts against the currently loaded specs. + + def conflicts + conflicts = {} + runtime_dependencies.each do |dep| + spec = Gem.loaded_specs[dep.name] + if spec && !spec.satisfies_requirement?(dep) + (conflicts[spec] ||= []) << dep + end + end + env_req = Gem.env_requirement(name) + (conflicts[self] ||= []) << env_req unless env_req.satisfied_by? version + conflicts + end + + ## + # return true if there will be conflict when spec if loaded together with the list of specs. + + def conflicts_when_loaded_with?(list_of_specs) # :nodoc: + result = list_of_specs.any? do |spec| + spec.runtime_dependencies.any? {|dep| (dep.name == name) && !satisfies_requirement?(dep) } + end + result + end + + ## + # Return true if there are possible conflicts against the currently loaded specs. + + def has_conflicts? + return true unless Gem.env_requirement(name).satisfied_by?(version) + runtime_dependencies.any? do |dep| + spec = Gem.loaded_specs[dep.name] + spec && !spec.satisfies_requirement?(dep) + end + rescue ArgumentError => e + raise e, "#{name} #{version}: #{e.message}" + end + + # The date this gem was created. + # + # If SOURCE_DATE_EPOCH is set as an environment variable, use that to support + # reproducible builds; otherwise, default to the current UTC date. + # + # Details on SOURCE_DATE_EPOCH: + # https://reproducible-builds.org/specs/source-date-epoch/ + + def date + @date ||= Time.utc(*Gem.source_date_epoch.utc.to_a[3..5].reverse) + end + + DateLike = Object.new # :nodoc: + def DateLike.===(obj) # :nodoc: + defined?(::Date) && Date === obj + end + + DateTimeFormat = # :nodoc: + /\A + (\d{4})-(\d{2})-(\d{2}) + (\s+ \d{2}:\d{2}:\d{2}\.\d+ \s* (Z | [-+]\d\d:\d\d) )? + \Z/x + + ## + # The date this gem was created + # + # DO NOT set this, it is set automatically when the gem is packaged. + + def date=(date) + # We want to end up with a Time object with one-day resolution. + # This is the cleanest, most-readable, faster-than-using-Date + # way to do it. + @date = case date + when String then + if DateTimeFormat =~ date + Time.utc($1.to_i, $2.to_i, $3.to_i) + else + raise(Gem::InvalidSpecificationException, + "invalid date format in specification: #{date.inspect}") + end + when Time, DateLike then + Time.utc(date.year, date.month, date.day) + else + TODAY + end + end + + ## + # The default value for specification attribute +name+ + + def default_value(name) + @@default_value[name] + end + + ## + # A list of Gem::Dependency objects this gem depends on. + # + # Use #add_dependency or #add_development_dependency to add dependencies to + # a gem. + + def dependencies + @dependencies ||= [] + end + + ## + # Return a list of all gems that have a dependency on this gemspec. The + # list is structured with entries that conform to: + # + # [depending_gem, dependency, [list_of_gems_that_satisfy_dependency]] + + def dependent_gems(check_dev = true) + out = [] + Gem::Specification.each do |spec| + deps = check_dev ? spec.dependencies : spec.runtime_dependencies + deps.each do |dep| + next unless satisfies_requirement?(dep) + sats = [] + find_all_satisfiers(dep) do |sat| + sats << sat + end + out << [spec, dep, sats] + end + end + out + end + + ## + # Returns all specs that matches this spec's runtime dependencies. + + def dependent_specs + runtime_dependencies.flat_map(&:to_specs) + end + + ## + # A detailed description of this gem. See also #summary + + def description=(str) + @description = str.to_s + end + + ## + # List of dependencies that are used for development + + def development_dependencies + dependencies.select {|d| d.type == :development } + end + + ## + # Returns the full path to this spec's documentation directory. If +type+ + # is given it will be appended to the end. For example: + # + # spec.doc_dir # => "/path/to/gem_repo/doc/a-1" + # + # spec.doc_dir 'ri' # => "/path/to/gem_repo/doc/a-1/ri" + + def doc_dir(type = nil) + @doc_dir ||= File.join base_dir, "doc", full_name + + if type + File.join @doc_dir, type + else + @doc_dir + end + end + + def encode_with(coder) # :nodoc: + coder.add "name", @name + coder.add "version", @version + coder.add "platform", platform.to_s + coder.add "original_platform", original_platform.to_s if platform.to_s != original_platform.to_s + + attributes = @@attributes.map(&:to_s) - %w[name version platform] + attributes.each do |name| + value = instance_variable_get("@#{name}") + coder.add name, value unless value.nil? + end + end + + def eql?(other) # :nodoc: + self.class === other && same_attributes?(other) + end + + ## + # Singular accessor for #executables + + def executable + (val = executables) && val.first + end + + ## + # Singular accessor for #executables + + def executable=(o) + self.executables = [o] + end + + ## + # Sets executables to +value+, ensuring it is an array. + + def executables=(value) + @executables = Array(value) + end + + ## + # Sets extensions to +extensions+, ensuring it is an array. + + def extensions=(extensions) + @extensions = Array extensions + end + + ## + # Sets extra_rdoc_files to +files+, ensuring it is an array. + + def extra_rdoc_files=(files) + @extra_rdoc_files = Array files + end + + ## + # The default (generated) file name of the gem. See also #spec_name. + # + # spec.file_name # => "example-1.0.gem" + + def file_name + "#{full_name}.gem" + end + + ## + # Sets files to +files+, ensuring it is an array. + + def files=(files) + @files = Array files + end + + ## + # Finds all gems that satisfy +dep+ + + def find_all_satisfiers(dep) + Gem::Specification.each do |spec| + yield spec if spec.satisfies_requirement? dep + end + end + + private :find_all_satisfiers + + ## + # Creates a duplicate spec without large blobs that aren't used at runtime. + + def for_cache + spec = dup + + spec.files = nil + spec.test_files = nil + + spec + end + + ## + # Work around old bundler versions removing my methods + # Can be removed once RubyGems can no longer install Bundler 2.5 + + def gem_dir # :nodoc: + super + end + + def gems_dir + @gems_dir ||= File.join(base_dir, "gems") + end + + ## + # True if this gem has files in test_files + + def has_unit_tests? # :nodoc: + !test_files.empty? + end + + # :stopdoc: + alias_method :has_test_suite?, :has_unit_tests? + # :startdoc: + + def hash # :nodoc: + name.hash ^ version.hash + end + + def init_with(coder) # :nodoc: + @installed_by_version ||= nil + yaml_initialize coder.tag, coder.map + end + + eval <<-RUBY, binding, __FILE__, __LINE__ + 1 + # frozen_string_literal: true + + def set_nil_attributes_to_nil + #{@@nil_attributes.map {|key| "@#{key} = nil" }.join "; "} + end + private :set_nil_attributes_to_nil + + def set_not_nil_attributes_to_default_values + #{@@non_nil_attributes.map {|key| "@#{key} = #{INITIALIZE_CODE_FOR_DEFAULTS[key]}" }.join ";"} + end + private :set_not_nil_attributes_to_default_values + RUBY + + ## + # Specification constructor. Assigns the default values to the attributes + # and yields itself for further initialization. Optionally takes +name+ and + # +version+. + + def initialize(name = nil, version = nil) + super() + @gems_dir = nil + @base_dir = nil + @loaded = false + @activated = false + @loaded_from = nil + @original_platform = nil + @installed_by_version = nil + + set_nil_attributes_to_nil + set_not_nil_attributes_to_default_values + + @new_platform = Gem::Platform::RUBY + + self.name = name if name + self.version = version if version + + if (platform = Gem.platforms.last) && platform != Gem::Platform::RUBY && platform != Gem::Platform.local + self.platform = platform + end + + yield self if block_given? + end + + ## + # Duplicates Array and Gem::Requirement attributes from +other_spec+ so state isn't shared. + # + + def initialize_copy(other_spec) + self.class.array_attributes.each do |name| + name = :"@#{name}" + next unless other_spec.instance_variable_defined? name + + begin + val = other_spec.instance_variable_get(name) + if val + instance_variable_set name, val.dup + elsif Gem.configuration.really_verbose + warn "WARNING: #{full_name} has an invalid nil value for #{name}" + end + rescue TypeError + e = Gem::FormatException.new \ + "#{full_name} has an invalid value for #{name}" + + e.file_path = loaded_from + raise e + end + end + + @required_ruby_version = other_spec.required_ruby_version.dup + @required_rubygems_version = other_spec.required_rubygems_version.dup + end + + def base_dir + return Gem.dir unless loaded_from + @base_dir ||= if default_gem? + File.dirname File.dirname File.dirname loaded_from + else + File.dirname File.dirname loaded_from + end + end + + def inspect # :nodoc: + if $DEBUG + super + else + "#{super[0..-2]} #{full_name}>" + end + end + + ## + # Files in the Gem under one of the require_paths + + def lib_files + @files.select do |file| + require_paths.any? do |path| + file.start_with? path + end + end + end + + ## + # Singular accessor for #licenses + + def license + licenses.first + end + + ## + # Plural accessor for setting licenses + # + # See #license= for details + + def licenses + @licenses ||= [] + end + + def internal_init # :nodoc: + super + @bin_dir = nil + @doc_dir = nil + @ri_dir = nil + @spec_dir = nil + @spec_file = nil + end + + ## + # Track removed method calls to warn about during build time. + # Warn about unknown attributes while loading a spec. + + def method_missing(sym, *a, &b) # :nodoc: + if REMOVED_METHODS.include?(sym) + removed_method_calls << sym + return + end + + if @specification_version > CURRENT_SPECIFICATION_VERSION && + sym.to_s.end_with?("=") + warn "ignoring #{sym} loading #{full_name}" if $DEBUG + else + super + end + end + + ## + # Is this specification missing its extensions? When this returns true you + # probably want to build_extensions + + def missing_extensions? + return false if RUBY_ENGINE == "jruby" + return false if extensions.empty? + return false if default_gem? + return false if File.exist? gem_build_complete_path + + true + end + + ## + # Normalize the list of files so that: + # * All file lists have redundancies removed. + # * Files referenced in the extra_rdoc_files are included in the package + # file list. + + def normalize + if defined?(@extra_rdoc_files) && @extra_rdoc_files + @extra_rdoc_files.uniq! + @files ||= [] + @files.concat(@extra_rdoc_files) + end + + @files = @files.uniq.sort if @files + @extensions = @extensions.uniq.sort if @extensions + @test_files = @test_files.uniq.sort if @test_files + @executables = @executables.uniq.sort if @executables + @extra_rdoc_files = @extra_rdoc_files.uniq.sort if @extra_rdoc_files + end + + ## + # Return a NameTuple that represents this Specification + + def name_tuple + Gem::NameTuple.new name, version, original_platform + end + + ## + # Returns the full name (name-version) of this gemspec using the original + # platform. For use with legacy gems. + + def original_name # :nodoc: + if platform == Gem::Platform::RUBY || platform.nil? + "#{@name}-#{@version}" + else + "#{@name}-#{@version}-#{@original_platform}" + end + end + + ## + # Cruft. Use +platform+. + + def original_platform # :nodoc: + @original_platform ||= platform + end + + ## + # The platform this gem runs on. See Gem::Platform for details. + + def platform + @new_platform ||= Gem::Platform::RUBY # rubocop:disable Naming/MemoizedInstanceVariableName + end + + def pretty_print(q) # :nodoc: + q.group 2, "Gem::Specification.new do |s|", "end" do + q.breakable + + attributes = @@attributes - [:name, :version] + attributes.unshift :installed_by_version + attributes.unshift :version + attributes.unshift :name + + attributes.each do |attr_name| + current_value = send attr_name + current_value = current_value.sort if [:files, :test_files].include? attr_name + next unless current_value != default_value(attr_name) || + self.class.required_attribute?(attr_name) + + q.text "s.#{attr_name} = " + + if attr_name == :date + current_value = current_value.utc + + q.text "Time.utc(#{current_value.year}, #{current_value.month}, #{current_value.day})" + else + q.pp current_value + end + + q.breakable + end + end + end + + ## + # Raise an exception if the version of this spec conflicts with the one + # that is already loaded (+other+) + + def check_version_conflict(other) # :nodoc: + return if version == other.version + + # This gem is already loaded. If the currently loaded gem is not in the + # list of candidate gems, then we have a version conflict. + + msg = "can't activate #{full_name}, already activated #{other.full_name}" + + e = Gem::LoadError.new msg + e.name = name + + raise e + end + + private :check_version_conflict + + ## + # Check the spec for possible conflicts and freak out if there are any. + + def raise_if_conflicts # :nodoc: + if has_conflicts? + raise Gem::ConflictError.new self, conflicts + end + end + + ## + # Sets rdoc_options to +value+, ensuring it is a flat array of strings. + # Handles malformed gemspecs where rdoc_options may be a Hash or contain Hashes. + + def rdoc_options=(options) + @rdoc_options = Array(options).flat_map do |opt| + opt.is_a?(Hash) ? opt.to_a.flatten.map(&:to_s) : opt + end + end + + ## + # Singular accessor for #require_paths + + def require_path + (val = require_paths) && val.first + end + + ## + # Singular accessor for #require_paths + + def require_path=(path) + self.require_paths = Array(path) + end + + ## + # Set requirements to +req+, ensuring it is an array. + + def requirements=(req) + @requirements = Array req + end + + def respond_to_missing?(m, include_private = false) # :nodoc: + false + end + + ## + # Returns the full path to this spec's ri directory. + + def ri_dir + @ri_dir ||= File.join base_dir, "ri", full_name + end + + ## + # Return a string containing a Ruby code representation of the given + # object. + + def ruby_code(obj) + case obj + when String then obj.dump + ".freeze" + when Array then "[" + obj.map {|x| ruby_code x }.join(", ") + "]" + when Hash then + seg = obj.keys.sort.map {|k| "#{k.to_s.dump} => #{obj[k].to_s.dump}" } + "{ #{seg.join(", ")} }" + when Gem::Version then ruby_code(obj.to_s) + when DateLike then obj.strftime("%Y-%m-%d").dump + when Time then obj.strftime("%Y-%m-%d").dump + when Numeric then obj.inspect + when true, false, nil then obj.inspect + when Gem::Platform then "Gem::Platform.new(#{ruby_code obj.to_a})" + when Gem::Requirement then + list = obj.as_list + "Gem::Requirement.new(#{ruby_code(list.size == 1 ? obj.to_s : list)})" + else raise Gem::Exception, "ruby_code case not handled: #{obj.class}" + end + end + + private :ruby_code + + ## + # List of dependencies that will automatically be activated at runtime. + + def runtime_dependencies + dependencies.select(&:runtime?) + end + + ## + # True if this gem has the same attributes as +other+. + + def same_attributes?(spec) + @@attributes.all? {|name, _default| send(name) == spec.send(name) } + end + + private :same_attributes? + + ## + # Checks if this specification meets the requirement of +dependency+. + + def satisfies_requirement?(dependency) + @name == dependency.name && + dependency.requirement.satisfied_by?(@version) + end + + ## + # Returns an object you can use to sort specifications in #sort_by. + + def sort_obj + [@name, @version, Gem::Platform.sort_priority(@new_platform)] + end + + ## + # Used by Gem::Resolver to order Gem::Specification objects + + def source # :nodoc: + Gem::Source::Installed.new + end + + ## + # Returns the full path to the directory containing this spec's + # gemspec file. eg: /usr/local/lib/ruby/gems/1.8/specifications + + def spec_dir + @spec_dir ||= File.join base_dir, "specifications" + end + + ## + # Returns the full path to this spec's gemspec file. + # eg: /usr/local/lib/ruby/gems/1.8/specifications/mygem-1.0.gemspec + + def spec_file + @spec_file ||= File.join spec_dir, "#{full_name}.gemspec" + end + + ## + # The default name of the gemspec. See also #file_name + # + # spec.spec_name # => "example-1.0.gemspec" + + def spec_name + "#{full_name}.gemspec" + end + + ## + # A short summary of this gem's description. + + def summary=(str) + @summary = str.to_s.strip. + gsub(/(\w-)\n[ \t]*(\w)/, '\1\2').gsub(/\n[ \t]*/, " ") # so. weird. + end + + ## + # Singular accessor for #test_files + + def test_file # :nodoc: + (val = test_files) && val.first + end + + ## + # Singular mutator for #test_files + + def test_file=(file) # :nodoc: + self.test_files = [file] + end + + ## + # Test files included in this gem. You cannot append to this accessor, you + # must assign to it. + + def test_files # :nodoc: + # Handle the possibility that we have @test_suite_file but not + # @test_files. This will happen when an old gem is loaded via + # YAML. + if defined? @test_suite_file + @test_files = [@test_suite_file].flatten + @test_suite_file = nil + end + if defined?(@test_files) && @test_files + @test_files + else + @test_files = [] + end + end + + ## + # Returns a Ruby code representation of this specification, such that it can + # be eval'ed and reconstruct the same specification later. Attributes that + # still have their default values are omitted. + + def to_ruby + result = [] + result << "# -*- encoding: utf-8 -*-" + result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{platform} #{raw_require_paths.join("\0")}" + result << "#{Gem::StubSpecification::PREFIX}#{extensions.join "\0"}" unless + extensions.empty? + result << nil + result << "Gem::Specification.new do |s|" + + result << " s.name = #{ruby_code name}" + result << " s.version = #{ruby_code version}" + unless platform.nil? || platform == Gem::Platform::RUBY + result << " s.platform = #{ruby_code original_platform}" + end + result << "" + result << " s.required_rubygems_version = #{ruby_code required_rubygems_version} if s.respond_to? :required_rubygems_version=" + + if metadata && !metadata.empty? + result << " s.metadata = #{ruby_code metadata} if s.respond_to? :metadata=" + end + result << " s.require_paths = #{ruby_code raw_require_paths}" + + handled = [ + :dependencies, + :name, + :platform, + :require_paths, + :required_rubygems_version, + :specification_version, + :version, + :metadata, + :signing_key, + ] + + @@attributes.each do |attr_name| + next if handled.include? attr_name + current_value = send(attr_name) + if current_value != default_value(attr_name) || self.class.required_attribute?(attr_name) + result << " s.#{attr_name} = #{ruby_code current_value}" + end + end + + if String === signing_key + result << " s.signing_key = #{ruby_code signing_key}" + end + + if @installed_by_version + result << nil + result << " s.installed_by_version = #{ruby_code Gem::VERSION}" + end + + unless dependencies.empty? + result << nil + result << " s.specification_version = #{specification_version}" + result << nil + + dependencies.each do |dep| + dep.instance_variable_set :@type, :runtime if dep.type.nil? # HACK + result << " s.add_#{dep.type}_dependency(%q<#{dep.name}>.freeze, #{ruby_code dep.requirements_list})" + end + end + + result << "end" + result << nil + + result.join "\n" + end + + ## + # Returns a Ruby lighter-weight code representation of this specification, + # used for indexing only. + # + # See #to_ruby. + + def to_ruby_for_cache + for_cache.to_ruby + end + + def to_s # :nodoc: + "#<Gem::Specification name=#{@name} version=#{@version}>" + end + + ## + # Returns self + + def to_spec + self + end + + def to_yaml(opts = {}) # :nodoc: + Gem.load_yaml + + if Gem.use_psych? + # Because the user can switch the YAML engine behind our + # back, we have to check again here to make sure that our + # psych code was properly loaded, and load it if not. + unless Gem.const_defined?(:NoAliasYAMLTree) + require_relative "psych_tree" + end + + builder = Gem::NoAliasYAMLTree.create + builder << self + ast = builder.tree + + require "stringio" + io = StringIO.new + io.set_encoding Encoding::UTF_8 + + Psych::Visitors::Emitter.new(io).accept(ast) + + io.string.gsub(/ !!null \n/, " \n") + else + Gem::YAMLSerializer.dump(self) + end + end + + ## + # Recursively walk dependencies of this spec, executing the +block+ for each + # hop. + + def traverse(trail = [], visited = {}, &block) + trail.push(self) + begin + runtime_dependencies.each do |dep| + dep.matching_specs(true).each do |dep_spec| + next if visited.key?(dep_spec) + visited[dep_spec] = true + trail.push(dep_spec) + begin + result = block[self, dep, dep_spec, trail] + ensure + trail.pop + end + next if result == :next + spec_name = dep_spec.name + dep_spec.traverse(trail, visited, &block) unless + trail.any? {|s| s.name == spec_name } + end + end + ensure + trail.pop + end + end + + ## + # Checks that the specification contains all required fields, and does a + # very basic sanity check. + # + # Raises InvalidSpecificationException if the spec does not pass the + # checks.. + + def validate(packaging = true, strict = false) + normalize + + validation_policy = Gem::SpecificationPolicy.new(self) + validation_policy.packaging = packaging + validation_policy.validate(strict) + end + + def keep_only_files_and_directories + @executables.delete_if {|x| File.directory?(File.join(@bindir, x)) } + @extensions.delete_if {|x| File.directory?(x) && !File.symlink?(x) } + @extra_rdoc_files.delete_if {|x| File.directory?(x) && !File.symlink?(x) } + @files.delete_if {|x| File.directory?(x) && !File.symlink?(x) } + @test_files.delete_if {|x| File.directory?(x) && !File.symlink?(x) } + end + + def validate_for_resolution + Gem::SpecificationPolicy.new(self).validate_for_resolution + end + + ## + # Set the version to +version+. + + def version=(version) + @version = version.nil? ? version : Gem::Version.create(version) + end + + def stubbed? + false + end + + def yaml_initialize(tag, vals) # :nodoc: + vals.each do |ivar, val| + case ivar + when "date" + # Force Date to go through the extra coerce logic in date= + self.date = val + when "platform" + self.platform = val + when "rdoc_options" + self.rdoc_options = val + when "requirements" + self.requirements = val + else + instance_variable_set "@#{ivar}", val + end + end + end + + ## + # Reset nil attributes to their default values to make the spec valid + + def reset_nil_attributes_to_default + nil_attributes = self.class.non_nil_attributes.find_all do |name| + !instance_variable_defined?("@#{name}") || instance_variable_get("@#{name}").nil? + end + + nil_attributes.each do |attribute| + default = default_value attribute + + value = case default + when Time, Numeric, Symbol, true, false, nil then default + else default.dup + end + + instance_variable_set "@#{attribute}", value + end + + @installed_by_version ||= nil + + nil + end + + def flatten_require_paths # :nodoc: + return unless raw_require_paths.first.is_a?(Array) + + warn "#{name} #{version} includes a gemspec with `require_paths` set to an array of arrays. Newer versions of this gem might've already fixed this" + raw_require_paths.flatten! + end + + def raw_require_paths # :nodoc: + @require_paths + end +end diff --git a/lib/rubygems/specification_policy.rb b/lib/rubygems/specification_policy.rb new file mode 100644 index 0000000000..478e294e09 --- /dev/null +++ b/lib/rubygems/specification_policy.rb @@ -0,0 +1,557 @@ +# frozen_string_literal: true + +require_relative "user_interaction" + +class Gem::SpecificationPolicy + include Gem::UserInteraction + + VALID_NAME_PATTERN = /\A[a-zA-Z0-9\.\-\_]+\z/ # :nodoc: + + SPECIAL_CHARACTERS = /\A[#{Regexp.escape(".-_")}]+/ # :nodoc: + + VALID_URI_PATTERN = %r{\Ahttps?:\/\/([^\s:@]+:[^\s:@]*@)?[A-Za-z\d\-]+(\.[A-Za-z\d\-]+)+\.?(:\d{1,5})?([\/?]\S*)?\z} # :nodoc: + + METADATA_LINK_KEYS = %w[ + homepage_uri + changelog_uri + source_code_uri + documentation_uri + wiki_uri + mailing_list_uri + bug_tracker_uri + download_uri + funding_uri + ].freeze # :nodoc: + + def initialize(specification) + @warnings = 0 + + @specification = specification + end + + ## + # If set to true, run packaging-specific checks, as well. + + attr_accessor :packaging + + ## + # Does a sanity check on the specification. + # + # Raises InvalidSpecificationException if the spec does not pass the + # checks. + # + # It also performs some validations that do not raise but print warning + # messages instead. + + def validate(strict = false) + validate_required! + validate_required_metadata! + + validate_optional(strict) if packaging || strict + + true + end + + ## + # Does a sanity check on the specification. + # + # Raises InvalidSpecificationException if the spec does not pass the + # checks. + # + # Only runs checks that are considered necessary for the specification to be + # functional. + + def validate_required! + validate_nil_attributes + + validate_rubygems_version + + validate_required_attributes + + validate_name + + validate_require_paths + + @specification.keep_only_files_and_directories + + validate_non_files + + validate_self_inclusion_in_files_list + + validate_specification_version + + validate_platform + + validate_array_attributes + + validate_authors_field + + validate_licenses_length + + validate_duplicate_dependencies + end + + def validate_required_metadata! + validate_metadata + + validate_lazy_metadata + end + + def validate_optional(strict) + validate_licenses + + validate_permissions + + validate_values + + validate_dependencies + + validate_required_ruby_version + + validate_extensions + + validate_removed_attributes + + validate_unique_links + + if @warnings > 0 + if strict + error "specification has warnings" + else + alert_warning help_text + end + end + end + + ## + # Implementation for Specification#validate_for_resolution + + def validate_for_resolution + validate_required! + end + + ## + # Implementation for Specification#validate_metadata + + def validate_metadata + metadata = @specification.metadata + + unless Hash === metadata + error "metadata must be a hash" + end + + metadata.each do |key, value| + entry = "metadata['#{key}']" + unless key.is_a?(String) + error "metadata keys must be a String" + end + + if key.size > 128 + error "metadata key is too large (#{key.size} > 128)" + end + + unless value.is_a?(String) + error "#{entry} value must be a String" + end + + if value.size > 1024 + error "#{entry} value is too large (#{value.size} > 1024)" + end + + next unless METADATA_LINK_KEYS.include? key + unless VALID_URI_PATTERN.match?(value) + error "#{entry} has invalid link: #{value.inspect}" + end + end + end + + ## + # Checks that no duplicate dependencies are specified. + + def validate_duplicate_dependencies # :nodoc: + # NOTE: see REFACTOR note in Gem::Dependency about types - this might be brittle + seen = Gem::Dependency::TYPES.inject({}) {|types, type| types.merge({ type => {} }) } + + error_messages = [] + @specification.dependencies.each do |dep| + if prev = seen[dep.type][dep.name] + error_messages << <<-MESSAGE +duplicate dependency on #{dep}, (#{prev.requirement}) use: + add_#{dep.type}_dependency \"#{dep.name}\", \"#{dep.requirement}\", \"#{prev.requirement}\" + MESSAGE + end + + seen[dep.type][dep.name] = dep + end + if error_messages.any? + error error_messages.join + end + end + + ## + # Checks that the gem does not depend on itself. + + def validate_dependencies # :nodoc: + error_messages = [] + @specification.dependencies.each do |dep| + if dep.name == @specification.name # error on self reference + error_messages << "Dependencies of this gem include a self-reference." + end + end + + error error_messages.join if error_messages.any? + end + + def validate_required_ruby_version + if @specification.required_ruby_version.requirements == [Gem::Requirement::DefaultRequirement] + warning "make sure you specify the oldest ruby version constraint (like \">= 3.0\") that you want your gem to support by setting the `required_ruby_version` gemspec attribute" + end + end + + ## + # Issues a warning for each file to be packaged which is world-readable. + # + # Implementation for Specification#validate_permissions + + def validate_permissions + return if Gem.win_platform? + + @specification.files.each do |file| + next unless File.file?(file) + next if File.stat(file).mode & 0o444 == 0o444 + warning "#{file} is not world-readable" + end + + @specification.executables.each do |name| + exec = File.join @specification.bindir, name + next unless File.file?(exec) + next if File.stat(exec).executable? + warning "#{exec} is not executable" + end + end + + private + + def validate_nil_attributes + nil_attributes = Gem::Specification.non_nil_attributes.select do |attrname| + @specification.instance_variable_get("@#{attrname}").nil? + end + return if nil_attributes.empty? + error "#{nil_attributes.join ", "} must not be nil" + end + + def validate_rubygems_version + return unless packaging + + rubygems_version = @specification.rubygems_version + + return if rubygems_version == Gem::VERSION + + warning "expected RubyGems version #{Gem::VERSION}, was #{rubygems_version}" + + @specification.rubygems_version = Gem::VERSION + end + + def validate_required_attributes + Gem::Specification.required_attributes.each do |symbol| + unless @specification.send symbol + error "missing value for attribute #{symbol}" + end + end + end + + def validate_name + name = @specification.name + + if !name.is_a?(String) + error "invalid value for attribute name: \"#{name.inspect}\" must be a string" + elsif !/[a-zA-Z]/.match?(name) + error "invalid value for attribute name: #{name.dump} must include at least one letter" + elsif !VALID_NAME_PATTERN.match?(name) + error "invalid value for attribute name: #{name.dump} can only include letters, numbers, dashes, and underscores" + elsif SPECIAL_CHARACTERS.match?(name) + error "invalid value for attribute name: #{name.dump} cannot begin with a period, dash, or underscore" + end + end + + def validate_require_paths + return unless @specification.raw_require_paths.empty? + + error "specification must have at least one require_path" + end + + def validate_non_files + return unless packaging + + non_files = @specification.files.reject {|x| File.file?(x) || File.symlink?(x) } + + unless non_files.empty? + error "[\"#{non_files.join "\", \""}\"] are not files" + end + end + + def validate_self_inclusion_in_files_list + file_name = @specification.file_name + + return unless @specification.files.include?(file_name) + + error "#{@specification.full_name} contains itself (#{file_name}), check your files list" + end + + def validate_specification_version + return if @specification.specification_version.is_a?(Integer) + + error "specification_version must be an Integer (did you mean version?)" + end + + def validate_platform + platform = @specification.platform + + case platform + when Gem::Platform, Gem::Platform::RUBY # ok + else + error "invalid platform #{platform.inspect}, see Gem::Platform" + end + end + + def validate_array_attributes + Gem::Specification.array_attributes.each do |field| + validate_array_attribute(field) + end + end + + def validate_array_attribute(field) + val = @specification.send(field) + klass = case field + when :dependencies then + Gem::Dependency + else + String + end + + unless Array === val && val.all? {|x| x.is_a?(klass) || (field == :licenses && x.nil?) } + error "#{field} must be an Array of #{klass}" + end + end + + def validate_authors_field + return unless @specification.authors.empty? + + error "authors may not be empty" + end + + def validate_licenses_length + licenses = @specification.licenses + + licenses.each do |license| + next if license.nil? + + if license.length > 64 + error "each license must be 64 characters or less" + end + end + end + + def validate_licenses + licenses = @specification.licenses + + licenses.each do |license| + next if Gem::Licenses.match?(license) || license.nil? + license_id_deprecated = Gem::Licenses.deprecated_license_id?(license) + exception_id_deprecated = Gem::Licenses.deprecated_exception_id?(license) + suggestions = Gem::Licenses.suggestions(license) + + if license_id_deprecated + main_message = "License identifier '#{license}' is deprecated" + elsif exception_id_deprecated + main_message = "Exception identifier at '#{license}' is deprecated" + else + main_message = "License identifier '#{license}' is invalid" + end + + message = <<-WARNING +#{main_message}. Use an identifier from +https://spdx.org/licenses or '#{Gem::Licenses::NONSTANDARD}' for a nonstandard license, +or set it to nil if you don't want to specify a license. + WARNING + message += "Did you mean #{suggestions.map {|s| "'#{s}'" }.join(", ")}?\n" unless suggestions.nil? + warning(message) + end + + warning <<-WARNING if licenses.empty? +licenses is empty, but is recommended. Use an license identifier from +https://spdx.org/licenses or '#{Gem::Licenses::NONSTANDARD}' for a nonstandard license, +or set it to nil if you don't want to specify a license. + WARNING + end + + LAZY = '"FIxxxXME" or "TOxxxDO"'.gsub(/xxx/, "") + LAZY_PATTERN = /\AFI XME|\ATO DO/x + HOMEPAGE_URI_PATTERN = /\A[a-z][a-z\d+.-]*:/i + + def validate_lazy_metadata + unless @specification.authors.grep(LAZY_PATTERN).empty? + error "#{LAZY} is not an author" + end + + unless Array(@specification.email).grep(LAZY_PATTERN).empty? + error "#{LAZY} is not an email" + end + + if LAZY_PATTERN.match?(@specification.description) + error "#{LAZY} is not a description" + end + + if LAZY_PATTERN.match?(@specification.summary) + error "#{LAZY} is not a summary" + end + + homepage = @specification.homepage + + # Make sure a homepage is valid HTTP/HTTPS URI + if homepage && !homepage.empty? + require_relative "vendor/uri/lib/uri" + begin + homepage_uri = Gem::URI.parse(homepage) + unless [Gem::URI::HTTP, Gem::URI::HTTPS].member? homepage_uri.class + error "\"#{homepage}\" is not a valid HTTP URI" + end + rescue Gem::URI::InvalidURIError + error "\"#{homepage}\" is not a valid HTTP URI" + end + end + end + + def validate_values + %w[author homepage summary files].each do |attribute| + validate_attribute_present(attribute) + end + + if @specification.description == @specification.summary + warning "description and summary are identical" + end + + # TODO: raise at some given date + warning "deprecated autorequire specified" if @specification.autorequire + + @specification.executables.each do |executable| + validate_executable(executable) + validate_shebang_line_in(executable) + end + + @specification.files.select {|f| File.symlink?(f) }.each do |file| + warning "#{file} is a symlink, which is not supported on all platforms" + end + end + + def validate_attribute_present(attribute) + value = @specification.send attribute + warning("no #{attribute} specified") if value.nil? || value.empty? + end + + def validate_executable(executable) + separators = [File::SEPARATOR, File::ALT_SEPARATOR, File::PATH_SEPARATOR].compact.map {|sep| Regexp.escape(sep) }.join + return unless executable.match?(/[\s#{separators}]/) + + error "executable \"#{executable}\" contains invalid characters" + end + + def validate_shebang_line_in(executable) + executable_path = File.join(@specification.bindir, executable) + return if File.read(executable_path, 2) == "#!" + + warning "#{executable_path} is missing #! line" + end + + def validate_removed_attributes # :nodoc: + @specification.removed_method_calls.each do |attr| + warning("#{attr} is deprecated and ignored. Please remove this from your gemspec to ensure that your gem continues to build in the future.") + end + end + + def validate_extensions # :nodoc: + require_relative "ext" + builder = Gem::Ext::Builder.new(@specification) + + validate_rake_extensions(builder) + validate_rust_extensions(builder) + validate_extension_require_relative + end + + def validate_rust_extensions(builder) # :nodoc: + rust_extension = @specification.extensions.any? {|s| builder.builder_for(s).is_a? Gem::Ext::CargoBuilder } + missing_cargo_lock = !@specification.files.any? {|f| f.end_with?("Cargo.lock") } + + error <<-ERROR if rust_extension && missing_cargo_lock +You have specified rust based extension, but Cargo.lock is not part of the gem files. Please run `cargo generate-lockfile` or any other command to generate Cargo.lock and ensure it is added to your gem files section in gemspec. + ERROR + end + + def validate_rake_extensions(builder) # :nodoc: + rake_extension = @specification.extensions.any? {|s| builder.builder_for(s) == Gem::Ext::RakeBuilder } + rake_dependency = @specification.dependencies.any? {|d| d.name == "rake" && d.type == :runtime } + + warning <<-WARNING if rake_extension && !rake_dependency +You have specified rake based extension, but rake is not added as runtime dependency. It is recommended to add rake as a runtime dependency in gemspec since there's no guarantee rake will be already installed. + WARNING + end + + def validate_extension_require_relative # :nodoc: + return unless @specification.extensions.any? + + require_paths = @specification.require_paths + + @specification.files.each do |rb_file| + next unless rb_file.end_with?(".rb") + next unless require_paths.any? {|rp| rb_file.start_with?("#{rp}/") } + next unless File.file?(rb_file) + + File.foreach(rb_file).with_index(1) do |line, lineno| + next unless line =~ /^\s*require_relative\s+["']([^"']+)["']/ + + required_path = Regexp.last_match(1) + resolved = File.join(File.dirname(rb_file), required_path) + + next if @specification.files.any? {|f| f == "#{resolved}.rb" || f == resolved } + + warning <<~WARNING + #{rb_file}:#{lineno} uses `require_relative "#{required_path}"` to load a compiled extension. + This will break in RubyGems 4.2, which will stop copying compiled extensions into the gem's lib directory. + Use `require` instead of `require_relative` to load compiled extensions. + WARNING + end + end + end + + def validate_unique_links + links = @specification.metadata.slice(*METADATA_LINK_KEYS) + grouped = links.group_by {|_key, uri| uri } + grouped.each do |uri, copies| + next unless copies.length > 1 + keys = copies.map(&:first).join("\n ") + warning <<~WARNING + You have specified the uri: + #{uri} + for all of the following keys: + #{keys} + Only the first one will be shown on rubygems.org + WARNING + end + end + + def warning(statement) # :nodoc: + @warnings += 1 + + alert_warning statement + end + + def error(statement) # :nodoc: + raise Gem::InvalidSpecificationException, statement + ensure + alert_warning help_text + end + + def help_text # :nodoc: + "See https://guides.rubygems.org/specification-reference/ for help" + end +end diff --git a/lib/rubygems/specification_record.rb b/lib/rubygems/specification_record.rb new file mode 100644 index 0000000000..c7e5cbedb5 --- /dev/null +++ b/lib/rubygems/specification_record.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +module Gem + class SpecificationRecord + def self.dirs_from(paths) + paths.map do |path| + File.join(path, "specifications") + end + end + + def self.from_path(path) + new(dirs_from([path])) + end + + def initialize(dirs) + @all = nil + @stubs = nil + @stubs_by_name = {} + @spec_with_requirable_file = {} + @active_stub_with_requirable_file = {} + + @dirs = dirs + end + + # Sentinel object to represent "not found" stubs + NOT_FOUND = Struct.new(:to_spec, :this).new + private_constant :NOT_FOUND + + ## + # Returns the list of all specifications in the record + + def all + @all ||= stubs.map(&:to_spec) + end + + ## + # Returns a Gem::StubSpecification for every specification in the record + + def stubs + @stubs ||= begin + pattern = "*.gemspec" + stubs = stubs_for_pattern(pattern, false) + + @stubs_by_name = stubs.select {|s| Gem::Platform.match_spec? s }.group_by(&:name) + stubs + end + end + + ## + # Returns a Gem::StubSpecification for every specification in the record + # named +name+ only returns stubs that match Gem.platforms + + def stubs_for(name) + if @stubs + @stubs_by_name[name] || [] + else + @stubs_by_name[name] ||= stubs_for_pattern("#{name}-*.gemspec").select do |s| + s.name == name + end + end + end + + ## + # Finds stub specifications matching a pattern in the record, optionally + # filtering out specs not matching the current platform + + def stubs_for_pattern(pattern, match_platform = true) + installed_stubs = installed_stubs(pattern) + installed_stubs.select! {|s| Gem::Platform.match_spec? s } if match_platform + stubs = installed_stubs + Gem::Specification.default_stubs(pattern) + Gem::Specification._resort!(stubs) + stubs + end + + ## + # Adds +spec+ to the record, keeping the collection properly sorted. + + def add_spec(spec) + return if all.include? spec + + all << spec + stubs << spec + (@stubs_by_name[spec.name] ||= []) << spec + + Gem::Specification._resort!(@stubs_by_name[spec.name]) + Gem::Specification._resort!(stubs) + end + + ## + # Removes +spec+ from the record. + + def remove_spec(spec) + all.delete spec.to_spec + stubs.delete spec + (@stubs_by_name[spec.name] || []).delete spec + end + + ## + # Sets the specs known by the record to +specs+. + + def all=(specs) + @stubs_by_name = specs.group_by(&:name) + @all = @stubs = specs + end + + ## + # Return full names of all specs in the record in sorted order. + + def all_names + all.map(&:full_name) + end + + include Enumerable + + ## + # Enumerate every known spec. + + def each + return enum_for(:each) unless block_given? + + all.each do |x| + yield x + end + end + + ## + # Returns every spec in the record that matches +name+ and optional +requirements+. + + def find_all_by_name(name, *requirements) + req = Gem::Requirement.create(*requirements) + env_req = Gem.env_requirement(name) + + matches = stubs_for(name).find_all do |spec| + req.satisfied_by?(spec.version) && env_req.satisfied_by?(spec.version) + end.map(&:to_spec) + + if name == "bundler" && !req.specific? + require_relative "bundler_version_finder" + Gem::BundlerVersionFinder.prioritize!(matches) + end + + matches + end + + ## + # Return the best specification in the record that contains the file matching +path+. + + def find_by_path(path) + path = path.dup.freeze + spec = @spec_with_requirable_file[path] ||= stubs.find do |s| + s.contains_requirable_file? path + end || NOT_FOUND + + spec.to_spec + end + + ## + # Return the best specification that contains the file matching +path+ + # amongst the specs that are not loaded. This method is different than + # +find_inactive_by_path+ as it will filter out loaded specs by their name. + + def find_unloaded_by_path(path) + stub = stubs.find do |s| + next if Gem.loaded_specs[s.name] + s.contains_requirable_file? path + end + stub&.to_spec + end + + ## + # Return the best specification in the record that contains the file + # matching +path+ amongst the specs that are not activated. + + def find_inactive_by_path(path) + stub = stubs.find do |s| + next if s.activated? + s.contains_requirable_file? path + end + stub&.to_spec + end + + ## + # Return the best specification in the record that contains the file + # matching +path+, among those already activated. + + def find_active_stub_by_path(path) + stub = @active_stub_with_requirable_file[path] ||= stubs.find do |s| + s.activated? && s.contains_requirable_file?(path) + end || NOT_FOUND + + stub.this + end + + ## + # Return the latest specs in the record, optionally including prerelease + # specs if +prerelease+ is true. + + def latest_specs(prerelease) + Gem::Specification._latest_specs stubs, prerelease + end + + ## + # Return the latest installed spec in the record for gem +name+. + + def latest_spec_for(name) + latest_specs(true).find {|installed_spec| installed_spec.name == name } + end + + private + + def installed_stubs(pattern) + map_stubs(pattern) do |path, base_dir, gems_dir| + Gem::StubSpecification.gemspec_stub(path, base_dir, gems_dir) + end + end + + def map_stubs(pattern) + @dirs.flat_map do |dir| + base_dir = File.dirname dir + gems_dir = File.join base_dir, "gems" + Gem::Specification.gemspec_stubs_in(dir, pattern) {|path| yield path, base_dir, gems_dir } + end + end + end +end diff --git a/lib/rubygems/ssl_certs/.document b/lib/rubygems/ssl_certs/.document new file mode 100644 index 0000000000..fb66f13c33 --- /dev/null +++ b/lib/rubygems/ssl_certs/.document @@ -0,0 +1 @@ +# Ignore all files in this directory diff --git a/lib/rubygems/ssl_certs/rubygems.org/GlobalSign.pem b/lib/rubygems/ssl_certs/rubygems.org/GlobalSign.pem new file mode 100644 index 0000000000..8afb219058 --- /dev/null +++ b/lib/rubygems/ssl_certs/rubygems.org/GlobalSign.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4 +MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8 +RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT +gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm +KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd +QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ +XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw +DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o +LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU +RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp +jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK +6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX +mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs +Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH +WD9f +-----END CERTIFICATE----- diff --git a/lib/rubygems/stub_specification.rb b/lib/rubygems/stub_specification.rb new file mode 100644 index 0000000000..53b337ed85 --- /dev/null +++ b/lib/rubygems/stub_specification.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +## +# Gem::StubSpecification reads the stub: line from the gemspec. This prevents +# us having to eval the entire gemspec in order to find out certain +# information. + +class Gem::StubSpecification < Gem::BasicSpecification + # :nodoc: + PREFIX = "# stub: " + + # :nodoc: + OPEN_MODE = "r:UTF-8:-" + + class StubLine # :nodoc: all + attr_reader :name, :version, :platform, :require_paths, :extensions, + :full_name + + NO_EXTENSIONS = [].freeze + + # These are common require paths. + REQUIRE_PATHS = { # :nodoc: + "lib" => "lib", + "test" => "test", + "ext" => "ext", + }.freeze + + # These are common require path lists. This hash is used to optimize + # and consolidate require_path objects. Most specs just specify "lib" + # in their require paths, so lets take advantage of that by pre-allocating + # a require path list for that case. + REQUIRE_PATH_LIST = { # :nodoc: + "lib" => ["lib"].freeze, + }.freeze + + def initialize(data, extensions) + parts = data[PREFIX.length..-1].split(" ", 4) + @name = -parts[0] + @version = if Gem::Version.correct?(parts[1]) + Gem::Version.new(parts[1]) + else + Gem::Version.new(0) + end + + @platform = Gem::Platform.new parts[2] + @extensions = extensions + @full_name = if platform == Gem::Platform::RUBY + "#{name}-#{version}" + else + "#{name}-#{version}-#{platform}" + end + + path_list = parts.last + @require_paths = REQUIRE_PATH_LIST[path_list] || path_list.split("\0").map! do |x| + REQUIRE_PATHS[x] || x + end + end + end + + def self.default_gemspec_stub(filename, base_dir, gems_dir) + new filename, base_dir, gems_dir, true + end + + def self.gemspec_stub(filename, base_dir, gems_dir) + new filename, base_dir, gems_dir, false + end + + attr_reader :base_dir, :gems_dir + + def initialize(filename, base_dir, gems_dir, default_gem) + super() + + self.loaded_from = filename + @data = nil + @name = nil + @spec = nil + @base_dir = base_dir + @gems_dir = gems_dir + @default_gem = default_gem + end + + ## + # True when this gem has been activated + + def activated? + @activated ||= !loaded_spec.nil? + end + + def default_gem? + @default_gem + end + + def build_extensions # :nodoc: + return if default_gem? + return if extensions.empty? + + to_spec.build_extensions + end + + ## + # If the gemspec contains a stubline, returns a StubLine instance. Otherwise + # returns the full Gem::Specification. + + def data + unless @data + begin + saved_lineno = $. + + Gem.open_file loaded_from, OPEN_MODE do |file| + file.readline # discard encoding line + stubline = file.readline + if stubline.start_with?(PREFIX) + extline = file.readline + + extensions = + if extline.delete_prefix!(PREFIX) + extline.chomp! + extline.split "\0" + else + StubLine::NO_EXTENSIONS + end + + stubline.chomp! # readline(chomp: true) allocates 3x as much as .readline.chomp! + @data = StubLine.new stubline, extensions + end + rescue EOFError + end + ensure + $. = saved_lineno + end + end + + @data ||= to_spec + end + + private :data + + def raw_require_paths # :nodoc: + data.require_paths + end + + def missing_extensions? + return false if RUBY_ENGINE == "jruby" + return false if default_gem? + return false if extensions.empty? + return false if File.exist? gem_build_complete_path + + to_spec.missing_extensions? + end + + ## + # Name of the gem + + def name + data.name + end + + ## + # Platform of the gem + + def platform + data.platform + end + + ## + # Extensions for this gem + + def extensions + data.extensions + end + + ## + # Version of the gem + + def version + data.version + end + + def full_name + data.full_name + end + + ## + # The full Gem::Specification for this gem, loaded from evalling its gemspec + + def spec + @spec ||= loaded_spec if @data + @spec ||= Gem::Specification.load(loaded_from) + end + alias_method :to_spec, :spec + + ## + # Is this StubSpecification valid? i.e. have we found a stub line, OR does + # the filename contain a valid gemspec? + + def valid? + data + end + + ## + # Is there a stub line present for this StubSpecification? + + def stubbed? + data.is_a? StubLine + end + + def ==(other) # :nodoc: + self.class === other && + name == other.name && + version == other.version && + platform == other.platform + end + + alias_method :eql?, :== # :nodoc: + + def hash # :nodoc: + name.hash ^ version.hash ^ platform.hash + end + + def <=>(other) # :nodoc: + sort_obj <=> other.sort_obj + end + + def sort_obj # :nodoc: + [name, version, Gem::Platform.sort_priority(platform)] + end + + private + + def loaded_spec + spec = Gem.loaded_specs[name] + return unless spec && spec.version == version && spec.default_gem? == default_gem? + + spec + end +end diff --git a/lib/rubygems/target_rbconfig.rb b/lib/rubygems/target_rbconfig.rb new file mode 100644 index 0000000000..21d90ee9db --- /dev/null +++ b/lib/rubygems/target_rbconfig.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rbconfig" + +## +# A TargetConfig is a wrapper around an RbConfig object that provides a +# consistent interface for querying configuration for *deployment target +# platform*, where the gem being installed is intended to run on. +# +# The TargetConfig is typically created from the RbConfig of the running Ruby +# process, but can also be created from an RbConfig file on disk for cross- +# compiling gems. + +class Gem::TargetRbConfig + attr_reader :path + + def initialize(rbconfig, path) + @rbconfig = rbconfig + @path = path + end + + ## + # Creates a TargetRbConfig for the platform that RubyGems is running on. + + def self.for_running_ruby + new(::RbConfig, nil) + end + + ## + # Creates a TargetRbConfig from the RbConfig file at the given path. + # Typically used for cross-compiling gems. + + def self.from_path(rbconfig_path) + namespace = Module.new do |m| + # Load the rbconfig.rb file within a new anonymous module to avoid + # conflicts with the rbconfig for the running platform. + Kernel.load rbconfig_path, m + end + rbconfig = namespace.const_get(:RbConfig) + + new(rbconfig, rbconfig_path) + end + + ## + # Queries the configuration for the given key. + + def [](key) + @rbconfig::CONFIG[key] + end +end diff --git a/lib/rubygems/text.rb b/lib/rubygems/text.rb new file mode 100644 index 0000000000..88d4ce59b4 --- /dev/null +++ b/lib/rubygems/text.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +## +# A collection of text-wrangling methods + +module Gem::Text + ## + # Remove any non-printable characters and make the text suitable for + # printing. + def clean_text(text) + text.gsub(/[\000-\b\v-\f\016-\037\177]/, ".") + end + + def truncate_text(text, description, max_length = 100_000) + raise ArgumentError, "max_length must be positive" unless max_length > 0 + return text if text.size <= max_length + "Truncating #{description} to #{max_length.to_s.reverse.gsub(/...(?=.)/,'\&,').reverse} characters:\n" + text[0, max_length] + end + + ## + # Wraps +text+ to +wrap+ characters and optionally indents by +indent+ + # characters + + def format_text(text, wrap, indent = 0) + result = [] + work = clean_text(text) + + while work.length > wrap do + if work =~ /^(.{0,#{wrap}})[ \n]/ + result << $1.rstrip + work.slice!(0, $&.length) + else + result << work.slice!(0, wrap) + end + end + + result << work if work.length.nonzero? + result.join("\n").gsub(/^/, " " * indent) + end + + def min3(a, b, c) # :nodoc: + if a < b && a < c + a + elsif b < c + b + else + c + end + end + + # Returns a value representing the "cost" of transforming str1 into str2 + # Vendored version of DidYouMean::Levenshtein.distance from the ruby/did_you_mean gem @ 1.4.0 + # https://github.com/ruby/did_you_mean/blob/2ddf39b874808685965dbc47d344cf6c7651807c/lib/did_you_mean/levenshtein.rb#L7-L37 + def levenshtein_distance(str1, str2) + n = str1.length + m = str2.length + return m if n.zero? + return n if m.zero? + + d = (0..m).to_a + x = nil + + # to avoid duplicating an enumerable object, create it outside of the loop + str2_codepoints = str2.codepoints + + str1.each_codepoint.with_index(1) do |char1, i| + j = 0 + while j < m + cost = char1 == str2_codepoints[j] ? 0 : 1 + x = min3( + d[j + 1] + 1, # insertion + i + 1, # deletion + d[j] + cost # substitution + ) + d[j] = i + i = x + + j += 1 + end + d[m] = x + end + + x + end +end diff --git a/lib/rubygems/uninstaller.rb b/lib/rubygems/uninstaller.rb new file mode 100644 index 0000000000..fe4c3a80cf --- /dev/null +++ b/lib/rubygems/uninstaller.rb @@ -0,0 +1,440 @@ +# frozen_string_literal: true + +#-- +# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others. +# All rights reserved. +# See LICENSE.txt for permissions. +#++ + +require "fileutils" +require_relative "../rubygems" +require_relative "installer_uninstaller_utils" +require_relative "dependency_list" +require_relative "user_interaction" + +## +# An Uninstaller. +# +# The uninstaller fires pre and post uninstall hooks. Hooks can be added +# either through a rubygems_plugin.rb file in an installed gem or via a +# rubygems/defaults/#{RUBY_ENGINE}.rb or rubygems/defaults/operating_system.rb +# file. See Gem.pre_uninstall and Gem.post_uninstall for details. + +class Gem::Uninstaller + include Gem::UserInteraction + + include Gem::InstallerUninstallerUtils + + ## + # The directory a gem's executables will be installed into + + attr_reader :bin_dir + + ## + # The gem repository the gem will be uninstalled from + + attr_reader :gem_home + + ## + # The Gem::Specification for the gem being uninstalled, only set during + # #uninstall_gem + + attr_reader :spec + + ## + # Constructs an uninstaller that will uninstall gem named +gem+. + # +options+ is a Hash with the following keys: + # + # :version:: Version requirement for the gem to uninstall. If not specified, + # uses Gem::Requirement.default. + # :install_dir:: The directory where the gem is installed. If not specified, + # uses Gem.dir. + # :executables:: Whether executables should be removed without confirmation or not. If nil, asks the user explicitly. + # :all:: If more than one version matches the requirement, whether to forcefully remove all matching versions or ask the user to select specific matching versions that should be removed. + # :ignore:: Ignore broken dependency checks when uninstalling. + # :bin_dir:: Directory containing executables to remove. If not specified, + # uses Gem.bindir. + # :format_executable:: In order to find executables to be removed, format executable names using Gem::Installer.exec_format. + # :abort_on_dependent:: Directly abort uninstallation if dependencies would be broken, rather than asking the user for confirmation. + # :check_dev:: When checking if uninstalling gem would leave broken dependencies around, also consider development dependencies. + # :force:: Set both :all and :ignore to true for forced uninstallation. + # :user_install:: Uninstall from user gem directory instead of system directory. + + def initialize(gem, options = {}) + @gem = gem + @version = options[:version] || Gem::Requirement.default + @install_dir = options[:install_dir] + @gem_home = File.realpath(@install_dir || Gem.dir) + @user_dir = File.exist?(Gem.user_dir) ? File.realpath(Gem.user_dir) : Gem.user_dir + @force_executables = options[:executables] + @force_all = options[:all] + @force_ignore = options[:ignore] + @bin_dir = options[:bin_dir] + @format_executable = options[:format_executable] + @abort_on_dependent = options[:abort_on_dependent] + @check_dev = options[:check_dev] + + if options[:force] + @force_all = true + @force_ignore = true + end + + # only add user directory if install_dir is not set + @user_install = false + @user_install = options[:user_install] unless @install_dir + + # Optimization: populated during #uninstall + @default_specs_matching_uninstall_params = [] + end + + ## + # Performs the uninstall of the gem. This removes the spec, the Gem + # directory, and the cached .gem file. + + def uninstall + dependency = Gem::Dependency.new @gem, @version + + list = [] + + specification_record.stubs.each do |spec| + next unless dependency.matches_spec? spec + + list << spec + end + + if list.empty? + raise Gem::InstallError, "gem #{@gem.inspect} is not installed" + end + + default_specs, list = list.partition(&:default_gem?) + warn_cannot_uninstall_default_gems(default_specs - list) + @default_specs_matching_uninstall_params = default_specs.map(&:to_spec) + + list, other_repo_specs = list.partition do |spec| + @gem_home == spec.base_dir || + (@user_install && spec.base_dir == @user_dir) + end + + list.sort! + + if list.empty? + return unless other_repo_specs.any? + + other_repos = other_repo_specs.map(&:base_dir).uniq + + message = ["#{@gem} is not installed in GEM_HOME, try:"] + message.concat other_repos.map {|repo| + "\tgem uninstall -i #{repo} #{@gem}" + } + + raise Gem::InstallError, message.join("\n") + elsif @force_all + remove_all list + + elsif list.size > 1 + gem_names = list.map(&:full_name_with_location) + gem_names << "All versions" + + say + _, index = choose_from_list "Select gem to uninstall:", gem_names + + if index == list.size + remove_all list + elsif index && index >= 0 && index < list.size + uninstall_gem list[index] + else + say "Error: must enter a number [1-#{list.size + 1}]" + end + else + uninstall_gem list.first + end + end + + ## + # Uninstalls gem +spec+ + + def uninstall_gem(stub) + spec = stub.to_spec + + @spec = spec + + unless dependencies_ok? spec + if abort_on_dependent? || !ask_if_ok(spec) + raise Gem::DependencyRemovalException, + "Uninstallation aborted due to dependent gem(s)" + end + end + + Gem.pre_uninstall_hooks.each do |hook| + hook.call self + end + + remove_executables @spec + remove_plugins @spec + remove @spec + + specification_record.remove_spec(stub) + + regenerate_plugins + + Gem.post_uninstall_hooks.each do |hook| + hook.call self + end + + @spec = nil + end + + ## + # Removes installed executables and batch files (windows only) for +spec+. + + def remove_executables(spec) + return if spec.executables.empty? || default_spec_matches?(spec) + + executables = spec.executables.clone + + # Leave any executables created by other installed versions + # of this gem installed. + + list = Gem::Specification.find_all do |s| + s.name == spec.name && s.version != spec.version + end + + list.each do |s| + s.executables.each do |exe_name| + executables.delete exe_name + end + end + + return if executables.empty? + + executables = executables.map {|exec| formatted_program_filename exec } + + remove = if @force_executables.nil? + ask_yes_no("Remove executables:\n" \ + "\t#{executables.join ", "}\n\n" \ + "in addition to the gem?", + true) + else + @force_executables + end + + if remove + bin_dir = @bin_dir || Gem.bindir(spec.base_dir) + + raise Gem::FilePermissionError, bin_dir unless File.writable? bin_dir + + executables.each do |exe_name| + say "Removing #{exe_name}" + + exe_file = File.join bin_dir, exe_name + + safe_delete { FileUtils.rm exe_file } + safe_delete { FileUtils.rm "#{exe_file}.bat" } + end + else + say "Executables and scripts will remain installed." + end + end + + ## + # Removes all gems in +list+. + # + # NOTE: removes uninstalled gems from +list+. + + def remove_all(list) + list.each {|spec| uninstall_gem spec } + end + + ## + # spec:: the spec of the gem to be uninstalled + + def remove(spec) + unless path_ok?(@gem_home, spec) || + (@user_install && path_ok?(@user_dir, spec)) + e = Gem::GemNotInHomeException.new \ + "Gem '#{spec.full_name}' is not installed in directory #{@gem_home}" + e.spec = spec + + raise e + end + + raise Gem::FilePermissionError, spec.base_dir unless + File.writable?(spec.base_dir) + + full_gem_path = spec.full_gem_path + exclusions = [] + + if default_spec_matches?(spec) && spec.executables.any? + exclusions = spec.executables.map {|exe| File.join(spec.bin_dir, exe) } + exclusions << File.dirname(exclusions.last) until exclusions.last == full_gem_path + end + + safe_delete { rm_r full_gem_path, exclusions: exclusions } + safe_delete { FileUtils.rm_r spec.extension_dir } + + old_platform_name = spec.original_name + + gem = spec.cache_file + gem = File.join(spec.cache_dir, "#{old_platform_name}.gem") unless + File.exist? gem + + safe_delete { FileUtils.rm_r gem } + + begin + Gem::RDoc.new(spec).remove + rescue NameError + end + + gemspec = spec.spec_file + + unless File.exist? gemspec + gemspec = File.join(File.dirname(gemspec), "#{old_platform_name}.gemspec") + end + + safe_delete { FileUtils.rm_r gemspec } + announce_deletion_of(spec) + end + + ## + # Remove any plugin wrappers for +spec+. + + def remove_plugins(spec) # :nodoc: + return if spec.plugins.empty? + + remove_plugins_for(spec, plugin_dir_for(spec)) + end + + ## + # Regenerates plugin wrappers after removal. + + def regenerate_plugins + latest = specification_record.latest_spec_for(@spec.name) + return if latest.nil? + + regenerate_plugins_for(latest, plugin_dir_for(@spec)) + end + + ## + # Is +spec+ in +gem_dir+? + + def path_ok?(gem_dir, spec) + full_path = File.join gem_dir, "gems", spec.full_name + original_path = File.join gem_dir, "gems", spec.original_name + + full_path == spec.full_gem_path || original_path == spec.full_gem_path + end + + ## + # Returns true if it is OK to remove +spec+ or this is a forced + # uninstallation. + + def dependencies_ok?(spec) # :nodoc: + return true if @force_ignore + + deplist = Gem::DependencyList.from_specs + deplist.ok_to_remove?(spec.full_name, @check_dev) + end + + ## + # Should the uninstallation abort if a dependency will go unsatisfied? + # + # See ::new. + + def abort_on_dependent? # :nodoc: + @abort_on_dependent + end + + ## + # Asks if it is OK to remove +spec+. Returns true if it is OK. + + def ask_if_ok(spec) # :nodoc: + msg = [""] + msg << "You have requested to uninstall the gem:" + msg << "\t#{spec.full_name}" + msg << "" + + siblings = Gem::Specification.select do |s| + s.name == spec.name && s.full_name != spec.full_name + end + + spec.dependent_gems(@check_dev).each do |dep_spec, dep, _satlist| + unless siblings.any? {|s| s.satisfies_requirement? dep } + msg << "#{dep_spec.name}-#{dep_spec.version} depends on #{dep}" + end + end + + msg << "If you remove this gem, these dependencies will not be met." + msg << "Continue with Uninstall?" + ask_yes_no(msg.join("\n"), false) + end + + ## + # Returns the formatted version of the executable +filename+ + + def formatted_program_filename(filename) # :nodoc: + # TODO perhaps the installer should leave a small manifest + # of what it did for us to find rather than trying to recreate + # it again. + if @format_executable + require_relative "installer" + Gem::Installer.exec_format % File.basename(filename) + else + filename + end + end + + def safe_delete(&block) + block.call + rescue Errno::ENOENT + nil + rescue Errno::EPERM + e = Gem::UninstallError.new + e.spec = @spec + + raise e + end + + private + + def rm_r(path, exclusions:) + FileUtils::Entry_.new(path).postorder_traverse do |ent| + ent.remove unless exclusions.include?(ent.path) + end + end + + def specification_record + @specification_record ||= @install_dir ? Gem::SpecificationRecord.from_path(@install_dir) : Gem::Specification.specification_record + end + + def announce_deletion_of(spec) + name = spec.full_name + say "Successfully uninstalled #{name}" + if default_spec_matches?(spec) + say( + "There was both a regular copy and a default copy of #{name}. The " \ + "regular copy was successfully uninstalled, but the default copy " \ + "was left around because default gems can't be removed." + ) + end + end + + # @return true if the specs of any default gems are `==` to the given `spec`. + def default_spec_matches?(spec) + !default_specs_that_match(spec).empty? + end + + # @return [Array] specs of default gems that are `==` to the given `spec`. + def default_specs_that_match(spec) + @default_specs_matching_uninstall_params.select {|default_spec| spec == default_spec } + end + + def warn_cannot_uninstall_default_gems(specs) + specs.each do |spec| + say "Gem #{spec.full_name} cannot be uninstalled because it is a default gem" + end + end + + def plugin_dir_for(spec) + Gem.plugindir(spec.base_dir) + end +end diff --git a/lib/rubygems/unknown_command_spell_checker.rb b/lib/rubygems/unknown_command_spell_checker.rb new file mode 100644 index 0000000000..ee5c2fbe04 --- /dev/null +++ b/lib/rubygems/unknown_command_spell_checker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Gem::UnknownCommandSpellChecker + attr_reader :error + + def initialize(error) + @error = error + end + + def corrections + @corrections ||= + spell_checker.correct(error.unknown_command).map(&:inspect) + end + + private + + def spell_checker + dictionary = Gem::CommandManager.instance.command_names + DidYouMean::SpellChecker.new(dictionary: dictionary) + end +end diff --git a/lib/rubygems/update_suggestion.rb b/lib/rubygems/update_suggestion.rb new file mode 100644 index 0000000000..6f3ec5f493 --- /dev/null +++ b/lib/rubygems/update_suggestion.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +## +# Mixin methods for Gem::Command to promote available RubyGems update + +module Gem::UpdateSuggestion + ONE_WEEK = 7 * 24 * 60 * 60 + + ## + # Message to promote available RubyGems update with related gem update command. + + def update_suggestion + <<-MESSAGE + +A new release of RubyGems is available: #{Gem.rubygems_version} → #{Gem.latest_rubygems_version}! +Run `gem update --system #{Gem.latest_rubygems_version}` to update your installation. + + MESSAGE + end + + ## + # Determines if current environment is eligible for update suggestion. + + def eligible_for_update? + # explicit opt-out + return false if Gem.configuration[:prevent_update_suggestion] + return false if ENV["RUBYGEMS_PREVENT_UPDATE_SUGGESTION"] + + # focus only on human usage of final RubyGems releases + return false unless Gem.ui.tty? + return false if Gem.rubygems_version.prerelease? + return false if Gem.disable_system_update_message + return false if Gem::CIDetector.ci? + + # check makes sense only when we can store timestamp of last try + # otherwise we will not be able to prevent "annoying" update message + # on each command call + return unless Gem.configuration.state_file_writable? + + # load time of last check, ensure the difference is enough to repeat the suggestion + check_time = Time.now.to_i + last_update_check = Gem.configuration.last_update_check + return false if (check_time - last_update_check) < ONE_WEEK + + # compare current and latest version, this is the part where + # latest rubygems spec is fetched from remote + (Gem.rubygems_version < Gem.latest_rubygems_version).tap do |eligible| + # store the time of last successful check into state file + Gem.configuration.last_update_check = check_time + + return eligible + end + rescue StandardError # don't block install command on any problem + false + end +end diff --git a/lib/rubygems/uri.rb b/lib/rubygems/uri.rb new file mode 100644 index 0000000000..d729c67d26 --- /dev/null +++ b/lib/rubygems/uri.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +## +# The Uri handles rubygems source URIs. +# + +class Gem::Uri + ## + # Parses and redacts uri + + def self.redact(uri) + new(uri).redacted + end + + ## + # Parses uri, raising if it's invalid + + def self.parse!(uri) + require_relative "vendor/uri/lib/uri" + + raise Gem::URI::InvalidURIError unless uri + + return uri unless uri.is_a?(String) + + # Always escape URI's to deal with potential spaces and such + # It should also be considered that source_uri may already be + # a valid URI with escaped characters. e.g. "{DESede}" is encoded + # as "%7BDESede%7D". If this is escaped again the percentage + # symbols will be escaped. + begin + Gem::URI.parse(uri) + rescue Gem::URI::InvalidURIError + Gem::URI.parse(Gem::URI::RFC2396_PARSER.escape(uri)) + end + end + + ## + # Parses uri, returning the original uri if it's invalid + + def self.parse(uri) + parse!(uri) + rescue Gem::URI::InvalidURIError + uri + end + + def initialize(source_uri) + @parsed_uri = parse(source_uri) + end + + def redacted + return self unless valid_uri? + + if token? || oauth_basic? + with_redacted_user + elsif password? + with_redacted_password + else + self + end + end + + def to_s + @parsed_uri.to_s + end + + def redact_credentials_from(text) + return text unless valid_uri? && password? && text.include?(to_s) + + text.sub(password, "REDACTED") + end + + def method_missing(method_name, *args, &blk) + if @parsed_uri.respond_to?(method_name) + @parsed_uri.send(method_name, *args, &blk) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + @parsed_uri.respond_to?(method_name, include_private) || super + end + + protected + + # Add a protected reader for the cloned instance to access the original object's parsed uri + attr_reader :parsed_uri + + private + + def parse!(uri) + self.class.parse!(uri) + end + + def parse(uri) + self.class.parse(uri) + end + + def with_redacted_user + clone.tap {|uri| uri.user = "REDACTED" } + end + + def with_redacted_password + clone.tap {|uri| uri.password = "REDACTED" } + end + + def valid_uri? + !@parsed_uri.is_a?(String) + end + + def password? + !!password + end + + def oauth_basic? + password == "x-oauth-basic" + end + + def token? + !user.nil? && password.nil? + end + + def initialize_copy(original) + @parsed_uri = original.parsed_uri.clone + end +end diff --git a/lib/rubygems/uri_formatter.rb b/lib/rubygems/uri_formatter.rb new file mode 100644 index 0000000000..8856fdadd2 --- /dev/null +++ b/lib/rubygems/uri_formatter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +## +# The UriFormatter handles URIs from user-input and escaping. +# +# uf = Gem::UriFormatter.new 'example.com' +# +# p uf.normalize #=> 'http://example.com' + +class Gem::UriFormatter + ## + # The URI to be formatted. + + attr_reader :uri + + ## + # Creates a new URI formatter for +uri+. + + def initialize(uri) + require "cgi/escape" + require "cgi/util" unless defined?(CGI::EscapeExt) + + @uri = uri + end + + ## + # Escapes the #uri for use as a CGI parameter + + def escape + return unless @uri + CGI.escape @uri + end + + ## + # Normalize the URI by adding "http://" if it is missing. + + def normalize + /^(https?|ftp|file):/i.match?(@uri) ? @uri : "http://#{@uri}" + end + + ## + # Unescapes the #uri which came from a CGI parameter + + def unescape + return unless @uri + CGI.unescape @uri + end +end diff --git a/lib/rubygems/user_interaction.rb b/lib/rubygems/user_interaction.rb new file mode 100644 index 0000000000..9fe3e755c4 --- /dev/null +++ b/lib/rubygems/user_interaction.rb @@ -0,0 +1,647 @@ +# 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 "text" + +## +# Module that defines the default UserInteraction. Any class including this +# module will have access to the +ui+ method that returns the default UI. + +module Gem::DefaultUserInteraction + include Gem::Text + + ## + # The default UI is a class variable of the singleton class for this + # module. + + @ui = nil + + ## + # Return the default UI. + + def self.ui + @ui ||= Gem::ConsoleUI.new + end + + ## + # Set the default UI. If the default UI is never explicitly set, a simple + # console based UserInteraction will be used automatically. + + def self.ui=(new_ui) + @ui = new_ui + end + + ## + # Use +new_ui+ for the duration of +block+. + + def self.use_ui(new_ui) + old_ui = @ui + @ui = new_ui + yield + ensure + @ui = old_ui + end + + ## + # See DefaultUserInteraction::ui + + def ui + Gem::DefaultUserInteraction.ui + end + + ## + # See DefaultUserInteraction::ui= + + def ui=(new_ui) + Gem::DefaultUserInteraction.ui = new_ui + end + + ## + # See DefaultUserInteraction::use_ui + + def use_ui(new_ui, &block) + Gem::DefaultUserInteraction.use_ui(new_ui, &block) + end +end + +## +# UserInteraction allows RubyGems to interact with the user through standard +# methods that can be replaced with more-specific UI methods for different +# displays. +# +# Since UserInteraction dispatches to a concrete UI class you may need to +# reference other classes for specific behavior such as Gem::ConsoleUI or +# Gem::SilentUI. +# +# Example: +# +# class X +# include Gem::UserInteraction +# +# def get_answer +# n = ask("What is the meaning of life?") +# end +# end + +module Gem::UserInteraction + include Gem::DefaultUserInteraction + + ## + # Displays an alert +statement+. Asks a +question+ if given. + + def alert(statement, question = nil) + ui.alert statement, question + end + + ## + # Displays an error +statement+ to the error output location. Asks a + # +question+ if given. + + def alert_error(statement, question = nil) + ui.alert_error statement, question + end + + ## + # Displays a warning +statement+ to the warning output location. Asks a + # +question+ if given. + + def alert_warning(statement, question = nil) + ui.alert_warning statement, question + end + + ## + # Asks a +question+ and returns the answer. + + def ask(question) + ui.ask question + end + + ## + # Asks for a password with a +prompt+ + + def ask_for_password(prompt) + ui.ask_for_password prompt + end + + ## + # Asks a yes or no +question+. Returns true for yes, false for no. + + def ask_yes_no(question, default = nil) + ui.ask_yes_no question, default + end + + ## + # Asks the user to answer +question+ with an answer from the given +list+. + + def choose_from_list(question, list) + ui.choose_from_list question, list + end + + ## + # Displays the given +statement+ on the standard output (or equivalent). + + def say(statement = "") + ui.say statement + end + + ## + # Terminates the RubyGems process with the given +exit_code+ + + def terminate_interaction(exit_code = 0) + ui.terminate_interaction exit_code + end + + ## + # Calls +say+ with +msg+ or the results of the block if really_verbose + # is true. + + def verbose(msg = nil) + say(clean_text(msg || yield)) if Gem.configuration.really_verbose + end +end + +## +# Gem::StreamUI implements a simple stream based user interface. + +class Gem::StreamUI + ## + # The input stream + + attr_reader :ins + + ## + # The output stream + + attr_reader :outs + + ## + # The error stream + + attr_reader :errs + + ## + # Creates a new StreamUI wrapping +in_stream+ for user input, +out_stream+ + # for standard output, +err_stream+ for error output. If +usetty+ is true + # then special operations (like asking for passwords) will use the TTY + # commands to disable character echo. + + def initialize(in_stream, out_stream, err_stream = $stderr, usetty = true) + @ins = in_stream + @outs = out_stream + @errs = err_stream + @usetty = usetty + end + + ## + # Returns true if TTY methods should be used on this StreamUI. + + def tty? + @usetty && @ins.tty? + end + + ## + # Prints a formatted backtrace to the errors stream if backtraces are + # enabled. + + def backtrace(exception) + return unless Gem.configuration.backtrace + + @errs.puts "\t#{exception.backtrace.join "\n\t"}" + end + + ## + # Choose from a list of options. +question+ is a prompt displayed above + # the list. +list+ is a list of option strings. Returns the pair + # [option_name, option_index]. + + def choose_from_list(question, list) + @outs.puts question + + list.each_with_index do |item, index| + @outs.puts " #{index + 1}. #{item}" + end + + @outs.print "> " + @outs.flush + + result = @ins.gets + + return nil, nil unless result + + result = result.strip.to_i - 1 + return nil, nil unless (0...list.size) === result + [list[result], result] + end + + ## + # Ask a question. Returns a true for yes, false for no. If not connected + # to a tty, raises an exception if default is nil, otherwise returns + # default. + + def ask_yes_no(question, default = nil) + unless tty? + if default.nil? + raise Gem::OperationNotSupportedError, + "Not connected to a tty and no default specified" + else + return default + end + end + + default_answer = case default + when nil + "yn" + when true + "Yn" + else + "yN" + end + + result = nil + + while result.nil? do + result = case ask "#{question} [#{default_answer}]" + when /^y/i then true + when /^n/i then false + when /^$/ then default + end + end + + result + end + + ## + # Ask a question. Returns an answer if connected to a tty, nil otherwise. + + def ask(question) + return nil unless tty? + + @outs.print(question + " ") + @outs.flush + + result = @ins.gets + result&.chomp! + result + end + + ## + # Ask for a password. Does not echo response to terminal. + + def ask_for_password(question) + return nil unless tty? + + @outs.print(question, " ") + @outs.flush + + password = _gets_noecho + @outs.puts + password&.chomp! + password + end + + def require_io_console + @require_io_console ||= begin + begin + require "io/console" + rescue LoadError + end + true + end + end + + def _gets_noecho + require_io_console + @ins.noecho { @ins.gets } + end + + ## + # Display a statement. + + def say(statement = "") + @outs.puts statement + end + + ## + # Display an informational alert. Will ask +question+ if it is not nil. + + def alert(statement, question = nil) + @outs.puts "INFO: #{statement}" + ask(question) if question + end + + ## + # Display a warning on stderr. Will ask +question+ if it is not nil. + + def alert_warning(statement, question = nil) + @errs.puts "WARNING: #{statement}" + ask(question) if question + end + + ## + # Display an error message in a location expected to get error messages. + # Will ask +question+ if it is not nil. + + def alert_error(statement, question = nil) + @errs.puts "ERROR: #{statement}" + ask(question) if question + end + + ## + # Terminate the application with exit code +status+, running any exit + # handlers that might have been defined. + + def terminate_interaction(status = 0) + close + raise Gem::SystemExitException, status + end + + def close + end + + ## + # Return a progress reporter object chosen from the current verbosity. + + def progress_reporter(*args) + case Gem.configuration.verbose + when nil, false + SilentProgressReporter.new(@outs, *args) + when true + SimpleProgressReporter.new(@outs, *args) + else + VerboseProgressReporter.new(@outs, *args) + end + end + + ## + # An absolutely silent progress reporter. + + class SilentProgressReporter + ## + # The count of items is never updated for the silent progress reporter. + + attr_reader :count + + ## + # Creates a silent progress reporter that ignores all input arguments. + + def initialize(out_stream, size, initial_message, terminal_message = nil) + end + + ## + # Does not print +message+ when updated as this object has taken a vow of + # silence. + + def updated(message) + end + + ## + # Does not print anything when complete as this object has taken a vow of + # silence. + + def done + end + end + + ## + # A basic dotted progress reporter. + + class SimpleProgressReporter + include Gem::DefaultUserInteraction + + ## + # The number of progress items counted so far. + + attr_reader :count + + ## + # Creates a new progress reporter that will write to +out_stream+ for + # +size+ items. Shows the given +initial_message+ when progress starts + # and the +terminal_message+ when it is complete. + + def initialize(out_stream, size, initial_message, terminal_message = "complete") + @out = out_stream + @total = size + @count = 0 + @terminal_message = terminal_message + + @out.puts initial_message + end + + ## + # Prints out a dot and ignores +message+. + + def updated(message) + @count += 1 + @out.print "." + @out.flush + end + + ## + # Prints out the terminal message. + + def done + @out.puts "\n#{@terminal_message}" + end + end + + ## + # A progress reporter that prints out messages about the current progress. + + class VerboseProgressReporter + include Gem::DefaultUserInteraction + + ## + # The number of progress items counted so far. + + attr_reader :count + + ## + # Creates a new progress reporter that will write to +out_stream+ for + # +size+ items. Shows the given +initial_message+ when progress starts + # and the +terminal_message+ when it is complete. + + def initialize(out_stream, size, initial_message, terminal_message = "complete") + @out = out_stream + @total = size + @count = 0 + @terminal_message = terminal_message + + @out.puts initial_message + end + + ## + # Prints out the position relative to the total and the +message+. + + def updated(message) + @count += 1 + @out.puts "#{@count}/#{@total}: #{message}" + end + + ## + # Prints out the terminal message. + + def done + @out.puts @terminal_message + end + end + + ## + # Return a download reporter object chosen from the current verbosity + + def download_reporter(*args) + if [nil, false].include?(Gem.configuration.verbose) || !@outs.tty? + SilentDownloadReporter.new(@outs, *args) + else + ThreadedDownloadReporter.new(@outs, *args) + end + end + + ## + # An absolutely silent download reporter. + + class SilentDownloadReporter + ## + # The silent download reporter ignores all arguments + + def initialize(out_stream, *args) + end + + ## + # The silent download reporter does not display +filename+ or care about + # +filesize+ because it is silent. + + def fetch(filename, filesize) + end + + ## + # Nothing can update the silent download reporter. + + def update(current) + end + + ## + # The silent download reporter won't tell you when the download is done. + # Because it is silent. + + def done + end + end + + ## + # A progress reporter that behaves nicely with threaded downloading. + + class ThreadedDownloadReporter + MUTEX = Thread::Mutex.new + + ## + # The current file name being displayed + + attr_reader :file_name + + ## + # Creates a new threaded download reporter that will display on + # +out_stream+. The other arguments are ignored. + + def initialize(out_stream, *args) + @file_name = nil + @out = out_stream + end + + ## + # Tells the download reporter that the +file_name+ is being fetched. + # The other arguments are ignored. + + def fetch(file_name, *args) + if @file_name.nil? + @file_name = file_name + locked_puts "Fetching #{@file_name}" + end + end + + ## + # Updates the threaded download reporter for the given number of +bytes+. + + def update(bytes) + # Do nothing. + end + + ## + # Indicates the download is complete. + + def done + # Do nothing. + end + + private + + def locked_puts(message) + MUTEX.synchronize do + @out.puts message + end + end + end +end + +## +# Subclass of StreamUI that instantiates the user interaction using $stdin, +# $stdout, and $stderr. + +class Gem::ConsoleUI < Gem::StreamUI + ## + # The Console UI has no arguments as it defaults to reading input from + # stdin, output to stdout and warnings or errors to stderr. + + def initialize + super $stdin, $stdout, $stderr, true + end +end + +## +# SilentUI is a UI choice that is absolutely silent. + +class Gem::SilentUI < Gem::StreamUI + ## + # The SilentUI has no arguments as it does not use any stream. + + def initialize + io = NullIO.new + super io, io, io, false + end + + def close + end + + def download_reporter(*args) # :nodoc: + SilentDownloadReporter.new(@outs, *args) + end + + def progress_reporter(*args) # :nodoc: + SilentProgressReporter.new(@outs, *args) + end + + ## + # An absolutely silent IO. + + class NullIO + def puts(*args) + end + + def print(*args) + end + + def flush + end + + def gets(*args) + end + + def tty? + false + end + end +end diff --git a/lib/rubygems/util.rb b/lib/rubygems/util.rb new file mode 100644 index 0000000000..ee4106c6ce --- /dev/null +++ b/lib/rubygems/util.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +## +# This module contains various utility methods as module methods. + +module Gem::Util + ## + # Zlib::GzipReader wrapper that unzips +data+. + + def self.gunzip(data) + require "zlib" + require "stringio" + data = StringIO.new(data, "r") + + gzip_reader = begin + Zlib::GzipReader.new(data) + rescue Zlib::GzipFile::Error => e + raise e.class, e.inspect, e.backtrace + end + + unzipped = gzip_reader.read + unzipped.force_encoding Encoding::BINARY + unzipped + end + + ## + # Zlib::GzipWriter wrapper that zips +data+. + + def self.gzip(data) + require "zlib" + require "stringio" + zipped = StringIO.new(String.new, "w") + zipped.set_encoding Encoding::BINARY + + Zlib::GzipWriter.wrap zipped do |io| + io.write data + end + + zipped.string + end + + ## + # A Zlib::Inflate#inflate wrapper + + def self.inflate(data) + require "zlib" + Zlib::Inflate.inflate data + end + + ## + # This calls IO.popen and reads the result + + def self.popen(*command) + IO.popen command, &:read + end + + ## + # Enumerates the parents of +directory+. + + def self.traverse_parents(directory, &block) + return enum_for __method__, directory unless block_given? + + here = File.expand_path directory + loop do + begin + Dir.chdir here, &block + rescue StandardError + Errno::EACCES + end + + new_here = File.expand_path("..", here) + return if new_here == here # toplevel + here = new_here + end + end + + ## + # Globs for files matching +pattern+ inside of +directory+, + # returning absolute paths to the matching files. + + def self.glob_files_in_dir(glob, base_path) + Dir.glob(glob, base: base_path).map! {|f| File.expand_path(f, base_path) } + end + + ## + # Corrects +path+ (usually returned by `Gem::URI.parse().path` on Windows), that + # comes with a leading slash. + + def self.correct_for_windows_path(path) + if path[0].chr == "/" && path[1].chr.match?(/[a-z]/i) && path[2].chr == ":" + path[1..-1] + else + path + end + end +end diff --git a/lib/rubygems/util/atomic_file_writer.rb b/lib/rubygems/util/atomic_file_writer.rb new file mode 100644 index 0000000000..32767c6a79 --- /dev/null +++ b/lib/rubygems/util/atomic_file_writer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Based on ActiveSupport's AtomicFile implementation +# Copyright (c) David Heinemeier Hansson +# https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/file/atomic.rb +# Licensed under the MIT License + +module Gem + class AtomicFileWriter + ## + # Write to a file atomically. Useful for situations where you don't + # want other processes or threads to see half-written files. + + def self.open(file_name) + require "securerandom" unless defined?(SecureRandom) + + old_stat = begin + File.stat(file_name) + rescue SystemCallError + nil + end + + # Names can't be longer than 255B + tmp_suffix = ".tmp.#{SecureRandom.hex}" + dirname = File.dirname(file_name) + basename = File.basename(file_name) + tmp_path = File.join(dirname, ".#{basename.byteslice(0, 254 - tmp_suffix.bytesize)}#{tmp_suffix}") + + flags = File::RDWR | File::CREAT | File::EXCL | File::BINARY + flags |= File::SHARE_DELETE if defined?(File::SHARE_DELETE) + + File.open(tmp_path, flags) do |temp_file| + temp_file.binmode + if old_stat + # Set correct permissions on new file + begin + File.chown(old_stat.uid, old_stat.gid, temp_file.path) + # This operation will affect filesystem ACL's + File.chmod(old_stat.mode, temp_file.path) + rescue Errno::EPERM, Errno::EACCES + # Changing file ownership failed, moving on. + end + end + + return_val = yield temp_file + rescue StandardError => error + begin + temp_file.close + rescue StandardError + nil + end + + begin + File.unlink(temp_file.path) + rescue StandardError + nil + end + + raise error + else + begin + File.rename(temp_file.path, file_name) + rescue StandardError + begin + File.unlink(temp_file.path) + rescue StandardError + end + + raise + end + + return_val + end + end + end +end diff --git a/lib/rubygems/util/licenses.rb b/lib/rubygems/util/licenses.rb new file mode 100644 index 0000000000..caf53d0b7e --- /dev/null +++ b/lib/rubygems/util/licenses.rb @@ -0,0 +1,888 @@ +# frozen_string_literal: true + +# This is generated by generate_spdx_license_list.rb, any edits to this +# file will be discarded. + +require_relative "../text" + +class Gem::Licenses + extend Gem::Text + + NONSTANDARD = "Nonstandard" + LICENSE_REF = "LicenseRef-.+" + + # Software Package Data Exchange (SPDX) standard open-source software + # license identifiers + LICENSE_IDENTIFIERS = %w[ + 0BSD + 3D-Slicer-1.0 + AAL + ADSL + AFL-1.1 + AFL-1.2 + AFL-2.0 + AFL-2.1 + AFL-3.0 + AGPL-1.0-only + AGPL-1.0-or-later + AGPL-3.0-only + AGPL-3.0-or-later + ALGLIB-Documentation + AMD-newlib + AMDPLPA + AML + AML-glslang + AMPAS + ANTLR-PD + ANTLR-PD-fallback + APAFML + APL-1.0 + APSL-1.0 + APSL-1.1 + APSL-1.2 + APSL-2.0 + ASWF-Digital-Assets-1.0 + ASWF-Digital-Assets-1.1 + Abstyles + AdaCore-doc + Adobe-2006 + Adobe-Display-PostScript + Adobe-Glyph + Adobe-Utopia + Advanced-Cryptics-Dictionary + Afmparse + Aladdin + Apache-1.0 + Apache-1.1 + Apache-2.0 + App-s2p + Arphic-1999 + Artistic-1.0 + Artistic-1.0-Perl + Artistic-1.0-cl8 + Artistic-2.0 + Artistic-dist + Aspell-RU + BOLA-1.1 + BSD-1-Clause + BSD-2-Clause + BSD-2-Clause-Darwin + BSD-2-Clause-Patent + BSD-2-Clause-Views + BSD-2-Clause-first-lines + BSD-2-Clause-pkgconf-disclaimer + BSD-3-Clause + BSD-3-Clause-Attribution + BSD-3-Clause-Clear + BSD-3-Clause-HP + BSD-3-Clause-LBNL + BSD-3-Clause-Modification + BSD-3-Clause-No-Military-License + BSD-3-Clause-No-Nuclear-License + BSD-3-Clause-No-Nuclear-License-2014 + BSD-3-Clause-No-Nuclear-Warranty + BSD-3-Clause-Open-MPI + BSD-3-Clause-Sun + BSD-3-Clause-Tso + BSD-3-Clause-acpica + BSD-3-Clause-flex + BSD-4-Clause + BSD-4-Clause-Shortened + BSD-4-Clause-UC + BSD-4.3RENO + BSD-4.3TAHOE + BSD-Advertising-Acknowledgement + BSD-Attribution-HPND-disclaimer + BSD-Inferno-Nettverk + BSD-Mark-Modifications + BSD-Protection + BSD-Source-Code + BSD-Source-beginning-file + BSD-Systemics + BSD-Systemics-W3Works + BSL-1.0 + BUSL-1.1 + Baekmuk + Bahyph + Barr + Beerware + BitTorrent-1.0 + BitTorrent-1.1 + Bitstream-Charter + Bitstream-Vera + BlueOak-1.0.0 + Boehm-GC + Boehm-GC-without-fee + Borceux + Brian-Gladman-2-Clause + Brian-Gladman-3-Clause + Buddy + C-UDA-1.0 + CAL-1.0 + CAL-1.0-Combined-Work-Exception + CAPEC-tou + CATOSL-1.1 + CC-BY-1.0 + CC-BY-2.0 + CC-BY-2.5 + CC-BY-2.5-AU + CC-BY-3.0 + CC-BY-3.0-AT + CC-BY-3.0-AU + CC-BY-3.0-DE + CC-BY-3.0-IGO + CC-BY-3.0-NL + CC-BY-3.0-US + CC-BY-4.0 + CC-BY-NC-1.0 + CC-BY-NC-2.0 + CC-BY-NC-2.5 + CC-BY-NC-3.0 + CC-BY-NC-3.0-DE + CC-BY-NC-4.0 + CC-BY-NC-ND-1.0 + CC-BY-NC-ND-2.0 + CC-BY-NC-ND-2.5 + CC-BY-NC-ND-3.0 + CC-BY-NC-ND-3.0-DE + CC-BY-NC-ND-3.0-IGO + CC-BY-NC-ND-4.0 + CC-BY-NC-SA-1.0 + CC-BY-NC-SA-2.0 + CC-BY-NC-SA-2.0-DE + CC-BY-NC-SA-2.0-FR + CC-BY-NC-SA-2.0-UK + CC-BY-NC-SA-2.5 + CC-BY-NC-SA-3.0 + CC-BY-NC-SA-3.0-DE + CC-BY-NC-SA-3.0-IGO + CC-BY-NC-SA-4.0 + CC-BY-ND-1.0 + CC-BY-ND-2.0 + CC-BY-ND-2.5 + CC-BY-ND-3.0 + CC-BY-ND-3.0-DE + CC-BY-ND-4.0 + CC-BY-SA-1.0 + CC-BY-SA-2.0 + CC-BY-SA-2.0-UK + CC-BY-SA-2.1-JP + CC-BY-SA-2.5 + CC-BY-SA-3.0 + CC-BY-SA-3.0-AT + CC-BY-SA-3.0-DE + CC-BY-SA-3.0-IGO + CC-BY-SA-4.0 + CC-PDDC + CC-PDM-1.0 + CC-SA-1.0 + CC0-1.0 + CDDL-1.0 + CDDL-1.1 + CDL-1.0 + CDLA-Permissive-1.0 + CDLA-Permissive-2.0 + CDLA-Sharing-1.0 + CECILL-1.0 + CECILL-1.1 + CECILL-2.0 + CECILL-2.1 + CECILL-B + CECILL-C + CERN-OHL-1.1 + CERN-OHL-1.2 + CERN-OHL-P-2.0 + CERN-OHL-S-2.0 + CERN-OHL-W-2.0 + CFITSIO + CMU-Mach + CMU-Mach-nodoc + CNRI-Jython + CNRI-Python + CNRI-Python-GPL-Compatible + COIL-1.0 + CPAL-1.0 + CPL-1.0 + CPOL-1.02 + CUA-OPL-1.0 + Caldera + Caldera-no-preamble + Catharon + ClArtistic + Clips + Community-Spec-1.0 + Condor-1.1 + Cornell-Lossless-JPEG + Cronyx + Crossword + CryptoSwift + CrystalStacker + Cube + D-FSL-1.0 + DEC-3-Clause + DL-DE-BY-2.0 + DL-DE-ZERO-2.0 + DOC + DRL-1.0 + DRL-1.1 + DSDP + DocBook-DTD + DocBook-Schema + DocBook-Stylesheet + DocBook-XML + Dotseqn + ECL-1.0 + ECL-2.0 + EFL-1.0 + EFL-2.0 + EPICS + EPL-1.0 + EPL-2.0 + ESA-PL-permissive-2.4 + ESA-PL-strong-copyleft-2.4 + ESA-PL-weak-copyleft-2.4 + EUDatagrid + EUPL-1.0 + EUPL-1.1 + EUPL-1.2 + Elastic-2.0 + Entessa + ErlPL-1.1 + Eurosym + FBM + FDK-AAC + FSFAP + FSFAP-no-warranty-disclaimer + FSFUL + FSFULLR + FSFULLRSD + FSFULLRWD + FSL-1.1-ALv2 + FSL-1.1-MIT + FTL + Fair + Ferguson-Twofish + Frameworx-1.0 + FreeBSD-DOC + FreeImage + Furuseth + GCR-docs + GD + GFDL-1.1-invariants-only + GFDL-1.1-invariants-or-later + GFDL-1.1-no-invariants-only + GFDL-1.1-no-invariants-or-later + GFDL-1.1-only + GFDL-1.1-or-later + GFDL-1.2-invariants-only + GFDL-1.2-invariants-or-later + GFDL-1.2-no-invariants-only + GFDL-1.2-no-invariants-or-later + GFDL-1.2-only + GFDL-1.2-or-later + GFDL-1.3-invariants-only + GFDL-1.3-invariants-or-later + GFDL-1.3-no-invariants-only + GFDL-1.3-no-invariants-or-later + GFDL-1.3-only + GFDL-1.3-or-later + GL2PS + GLWTPL + GPL-1.0-only + GPL-1.0-or-later + GPL-2.0-only + GPL-2.0-or-later + GPL-3.0-only + GPL-3.0-or-later + Game-Programming-Gems + Giftware + Glide + Glulxe + Graphics-Gems + Gutmann + HDF5 + HIDAPI + HP-1986 + HP-1989 + HPND + HPND-DEC + HPND-Fenneberg-Livingston + HPND-INRIA-IMAG + HPND-Intel + HPND-Kevlin-Henney + HPND-MIT-disclaimer + HPND-Markus-Kuhn + HPND-Netrek + HPND-Pbmplus + HPND-SMC + HPND-UC + HPND-UC-export-US + HPND-doc + HPND-doc-sell + HPND-export-US + HPND-export-US-acknowledgement + HPND-export-US-modify + HPND-export2-US + HPND-merchantability-variant + HPND-sell-MIT-disclaimer-xserver + HPND-sell-regexpr + HPND-sell-variant + HPND-sell-variant-MIT-disclaimer + HPND-sell-variant-MIT-disclaimer-rev + HPND-sell-variant-critical-systems + HTMLTIDY + HaskellReport + Hippocratic-2.1 + IBM-pibs + ICU + IEC-Code-Components-EULA + IJG + IJG-short + IPA + IPL-1.0 + ISC + ISC-Veillard + ISO-permission + ImageMagick + Imlib2 + Info-ZIP + Inner-Net-2.0 + InnoSetup + Intel + Intel-ACPI + Interbase-1.0 + JPL-image + JPNIC + JSON + Jam + JasPer-2.0 + Kastrup + Kazlib + Knuth-CTAN + LAL-1.2 + LAL-1.3 + LGPL-2.0-only + LGPL-2.0-or-later + LGPL-2.1-only + LGPL-2.1-or-later + LGPL-3.0-only + LGPL-3.0-or-later + LGPLLR + LOOP + LPD-document + LPL-1.0 + LPL-1.02 + LPPL-1.0 + LPPL-1.1 + LPPL-1.2 + LPPL-1.3a + LPPL-1.3c + LZMA-SDK-9.11-to-9.20 + LZMA-SDK-9.22 + Latex2e + Latex2e-translated-notice + Leptonica + LiLiQ-P-1.1 + LiLiQ-R-1.1 + LiLiQ-Rplus-1.1 + Libpng + Linux-OpenIB + Linux-man-pages-1-para + Linux-man-pages-copyleft + Linux-man-pages-copyleft-2-para + Linux-man-pages-copyleft-var + Lucida-Bitmap-Fonts + MIPS + MIT + MIT-0 + MIT-CMU + MIT-Click + MIT-Festival + MIT-Khronos-old + MIT-Modern-Variant + MIT-STK + MIT-Wu + MIT-advertising + MIT-enna + MIT-feh + MIT-open-group + MIT-testregex + MITNFA + MMIXware + MMPL-1.0.1 + MPEG-SSG + MPL-1.0 + MPL-1.1 + MPL-2.0 + MPL-2.0-no-copyleft-exception + MS-LPL + MS-PL + MS-RL + MTLL + Mackerras-3-Clause + Mackerras-3-Clause-acknowledgment + MakeIndex + Martin-Birgmeier + McPhee-slideshow + Minpack + MirOS + Motosoto + MulanPSL-1.0 + MulanPSL-2.0 + Multics + Mup + NAIST-2003 + NASA-1.3 + NBPL-1.0 + NCBI-PD + NCGL-UK-2.0 + NCL + NCSA + NGPL + NICTA-1.0 + NIST-PD + NIST-PD-TNT + NIST-PD-fallback + NIST-Software + NLOD-1.0 + NLOD-2.0 + NLPL + NOSL + NPL-1.0 + NPL-1.1 + NPOSL-3.0 + NRL + NTIA-PD + NTP + NTP-0 + Naumen + NetCDF + Newsletr + Nokia + Noweb + O-UDA-1.0 + OAR + OCCT-PL + OCLC-2.0 + ODC-By-1.0 + ODbL-1.0 + OFFIS + OFL-1.0 + OFL-1.0-RFN + OFL-1.0-no-RFN + OFL-1.1 + OFL-1.1-RFN + OFL-1.1-no-RFN + OGC-1.0 + OGDL-Taiwan-1.0 + OGL-Canada-2.0 + OGL-UK-1.0 + OGL-UK-2.0 + OGL-UK-3.0 + OGTSL + OLDAP-1.1 + OLDAP-1.2 + OLDAP-1.3 + OLDAP-1.4 + OLDAP-2.0 + OLDAP-2.0.1 + OLDAP-2.1 + OLDAP-2.2 + OLDAP-2.2.1 + OLDAP-2.2.2 + OLDAP-2.3 + OLDAP-2.4 + OLDAP-2.5 + OLDAP-2.6 + OLDAP-2.7 + OLDAP-2.8 + OLFL-1.3 + OML + OPL-1.0 + OPL-UK-3.0 + OPUBL-1.0 + OSC-1.0 + OSET-PL-2.1 + OSL-1.0 + OSL-1.1 + OSL-2.0 + OSL-2.1 + OSL-3.0 + OSSP + OpenMDW-1.0 + OpenPBS-2.3 + OpenSSL + OpenSSL-standalone + OpenVision + PADL + PDDL-1.0 + PHP-3.0 + PHP-3.01 + PPL + PSF-2.0 + ParaType-Free-Font-1.3 + Parity-6.0.0 + Parity-7.0.0 + Pixar + Plexus + PolyForm-Noncommercial-1.0.0 + PolyForm-Small-Business-1.0.0 + PostgreSQL + Python-2.0 + Python-2.0.1 + QPL-1.0 + QPL-1.0-INRIA-2004 + Qhull + RHeCos-1.1 + RPL-1.1 + RPL-1.5 + RPSL-1.0 + RSA-MD + RSCPL + Rdisc + Ruby + Ruby-pty + SAX-PD + SAX-PD-2.0 + SCEA + SGI-B-1.0 + SGI-B-1.1 + SGI-B-2.0 + SGI-OpenGL + SGMLUG-PM + SGP4 + SHL-0.5 + SHL-0.51 + SISSL + SISSL-1.2 + SL + SMAIL-GPL + SMLNJ + SMPPL + SNIA + SOFA + SPL-1.0 + SSH-OpenSSH + SSH-short + SSLeay-standalone + SSPL-1.0 + SUL-1.0 + SWL + Saxpath + SchemeReport + Sendmail + Sendmail-8.23 + Sendmail-Open-Source-1.1 + SimPL-2.0 + Sleepycat + Soundex + Spencer-86 + Spencer-94 + Spencer-99 + SugarCRM-1.1.3 + Sun-PPP + Sun-PPP-2000 + SunPro + Symlinks + TAPR-OHL-1.0 + TCL + TCP-wrappers + TGPPL-1.0 + TMate + TORQUE-1.1 + TOSL + TPDL + TPL-1.0 + TTWL + TTYP0 + TU-Berlin-1.0 + TU-Berlin-2.0 + TekHVC + TermReadKey + ThirdEye + TrustedQSL + UCAR + UCL-1.0 + UMich-Merit + UPL-1.0 + URT-RLE + Ubuntu-font-1.0 + UnRAR + Unicode-3.0 + Unicode-DFS-2015 + Unicode-DFS-2016 + Unicode-TOU + UnixCrypt + Unlicense + Unlicense-libtelnet + Unlicense-libwhirlpool + VOSTROM + VSL-1.0 + Vim + Vixie-Cron + W3C + W3C-19980720 + W3C-20150513 + WTFNMFPL + WTFPL + Watcom-1.0 + Widget-Workshop + WordNet + Wsuipa + X11 + X11-distribute-modifications-variant + X11-no-permit-persons + X11-swapped + XFree86-1.1 + XSkat + Xdebug-1.03 + Xerox + Xfig + Xnet + YPL-1.0 + YPL-1.1 + ZPL-1.1 + ZPL-2.0 + ZPL-2.1 + Zed + Zeeff + Zend-2.0 + Zimbra-1.3 + Zimbra-1.4 + Zlib + any-OSI + any-OSI-perl-modules + bcrypt-Solar-Designer + blessing + bzip2-1.0.6 + check-cvs + checkmk + copyleft-next-0.3.0 + copyleft-next-0.3.1 + curl + cve-tou + diffmark + dtoa + dvipdfm + eGenix + etalab-2.0 + fwlw + gSOAP-1.3b + generic-xts + gnuplot + gtkbook + hdparm + hyphen-bulgarian + iMatix + jove + libpng-1.6.35 + libpng-2.0 + libselinux-1.0 + libtiff + libutil-David-Nugent + lsof + magaz + mailprio + man2html + metamail + mpi-permissive + mpich2 + mplus + ngrep + pkgconf + pnmstitch + psfrag + psutils + python-ldap + radvd + snprintf + softSurfer + ssh-keyscan + swrule + threeparttable + ulem + w3m + wwl + xinetd + xkeyboard-config-Zinoviev + xlock + xpp + xzoom + zlib-acknowledgement + ].freeze + + DEPRECATED_LICENSE_IDENTIFIERS = %w[ + AGPL-1.0 + AGPL-3.0 + BSD-2-Clause-FreeBSD + BSD-2-Clause-NetBSD + GFDL-1.1 + GFDL-1.2 + GFDL-1.3 + GPL-1.0 + GPL-1.0+ + GPL-2.0 + GPL-2.0+ + GPL-2.0-with-GCC-exception + GPL-2.0-with-autoconf-exception + GPL-2.0-with-bison-exception + GPL-2.0-with-classpath-exception + GPL-2.0-with-font-exception + GPL-3.0 + GPL-3.0+ + GPL-3.0-with-GCC-exception + GPL-3.0-with-autoconf-exception + LGPL-2.0 + LGPL-2.0+ + LGPL-2.1 + LGPL-2.1+ + LGPL-3.0 + LGPL-3.0+ + Net-SNMP + Nunit + StandardML-NJ + bzip2-1.0.5 + eCos-2.0 + wxWindows + ].freeze + + # exception identifiers + EXCEPTION_IDENTIFIERS = %w[ + 389-exception + Asterisk-exception + Asterisk-linking-protocols-exception + Autoconf-exception-2.0 + Autoconf-exception-3.0 + Autoconf-exception-generic + Autoconf-exception-generic-3.0 + Autoconf-exception-macro + Bison-exception-1.24 + Bison-exception-2.2 + Bootloader-exception + CGAL-linking-exception + CLISP-exception-2.0 + Classpath-exception-2.0 + Classpath-exception-2.0-short + DigiRule-FOSS-exception + Digia-Qt-LGPL-exception-1.1 + FLTK-exception + Fawkes-Runtime-exception + Font-exception-2.0 + GCC-exception-2.0 + GCC-exception-2.0-note + GCC-exception-3.1 + GNAT-exception + GNOME-examples-exception + GNU-compiler-exception + GPL-3.0-389-ds-base-exception + GPL-3.0-interface-exception + GPL-3.0-linking-exception + GPL-3.0-linking-source-exception + GPL-CC-1.0 + GStreamer-exception-2005 + GStreamer-exception-2008 + Gmsh-exception + Independent-modules-exception + KiCad-libraries-exception + LGPL-3.0-linking-exception + LLGPL + LLVM-exception + LZMA-exception + Libtool-exception + Linux-syscall-note + OCCT-exception-1.0 + OCaml-LGPL-linking-exception + OpenJDK-assembly-exception-1.0 + PCRE2-exception + PS-or-PDF-font-exception-20170817 + QPL-1.0-INRIA-2004-exception + Qt-GPL-exception-1.0 + Qt-LGPL-exception-1.1 + Qwt-exception-1.0 + RRDtool-FLOSS-exception-2.0 + SANE-exception + SHL-2.0 + SHL-2.1 + SWI-exception + Simple-Library-Usage-exception + Swift-exception + Texinfo-exception + UBDL-exception + Universal-FOSS-exception-1.0 + WxWindows-exception-3.1 + cryptsetup-OpenSSL-exception + eCos-exception-2.0 + erlang-otp-linking-exception + fmt-exception + freertos-exception-2.0 + gnu-javamail-exception + harbour-exception + i2p-gpl-java-exception + kvirc-openssl-exception + libpri-OpenH323-exception + mif-exception + mxml-exception + openvpn-openssl-exception + polyparse-exception + romic-exception + rsync-linking-exception + sqlitestudio-OpenSSL-exception + stunnel-exception + u-boot-exception-2.0 + vsftpd-openssl-exception + x11vnc-openssl-exception + ].freeze + + DEPRECATED_EXCEPTION_IDENTIFIERS = %w[ + Nokia-Qt-exception-1.1 + ].freeze + + VALID_REGEXP = / + \A + (?: + #{Regexp.union(LICENSE_IDENTIFIERS)} + \+? + (?:\s WITH \s #{Regexp.union(EXCEPTION_IDENTIFIERS)})? + | #{NONSTANDARD} + | #{LICENSE_REF} + ) + \Z + /ox + + DEPRECATED_LICENSE_REGEXP = / + \A + #{Regexp.union(DEPRECATED_LICENSE_IDENTIFIERS)} + \+? + (?:\s WITH \s .+?)? + \Z + /ox + + DEPRECATED_EXCEPTION_REGEXP = / + \A + .+? + \+? + (?:\s WITH \s #{Regexp.union(DEPRECATED_EXCEPTION_IDENTIFIERS)}) + \Z + /ox + + def self.match?(license) + VALID_REGEXP.match?(license) + end + + def self.deprecated_license_id?(license) + DEPRECATED_LICENSE_REGEXP.match?(license) + end + + def self.deprecated_exception_id?(license) + DEPRECATED_EXCEPTION_REGEXP.match?(license) + end + + def self.suggestions(license) + by_distance = LICENSE_IDENTIFIERS.group_by do |identifier| + levenshtein_distance(identifier, license) + end + lowest = by_distance.keys.min + return unless lowest < license.size + by_distance[lowest] + end +end diff --git a/lib/rubygems/validator.rb b/lib/rubygems/validator.rb new file mode 100644 index 0000000000..eb5b513570 --- /dev/null +++ b/lib/rubygems/validator.rb @@ -0,0 +1,144 @@ +# 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 "package" +require_relative "installer" + +## +# Validator performs various gem file and gem database validation + +class Gem::Validator + include Gem::UserInteraction + + def initialize # :nodoc: + require "find" + end + + private + + def find_files_for_gem(gem_directory) + installed_files = [] + + Find.find gem_directory do |file_name| + fn = file_name[gem_directory.size..file_name.size - 1].sub(%r{^/}, "") + installed_files << fn unless + fn.empty? || fn.include?("CVS") || File.directory?(file_name) + end + + installed_files + end + + public + + ## + # Describes a problem with a file in a gem. + + ErrorData = Struct.new :path, :problem do + def <=>(other) # :nodoc: + return nil unless self.class === other + + [path, problem] <=> [other.path, other.problem] + end + end + + ## + # Checks the gem directory for the following potential + # inconsistencies/problems: + # + # * Checksum gem itself + # * For each file in each gem, check consistency of installed versions + # * Check for files that aren't part of the gem but are in the gems directory + # * 1 cache - 1 spec - 1 directory. + # + # returns a hash of ErrorData objects, keyed on the problem gem's name. + #-- + # TODO needs further cleanup + + def alien(gems = []) + errors = Hash.new {|h,k| h[k] = {} } + + Gem::Specification.each do |spec| + unless gems.empty? + next unless gems.include? spec.name + end + next if spec.default_gem? + + gem_name = spec.file_name + gem_path = spec.cache_file + spec_path = spec.spec_file + gem_directory = spec.full_gem_path + + unless File.directory? gem_directory + errors[gem_name][spec.full_name] = + "Gem registered but doesn't exist at #{gem_directory}" + next + end + + unless File.exist? spec_path + errors[gem_name][spec_path] = "Spec file missing for installed gem" + end + + begin + unless File.readable?(gem_path) + raise Gem::VerificationError, "missing gem file #{gem_path}" + end + + good, gone, unreadable = nil, nil, nil, nil + + File.open gem_path, Gem.binary_mode do |_file| + package = Gem::Package.new gem_path + + good, gone = package.contents.partition do |file_name| + File.exist? File.join(gem_directory, file_name) + end + + gone.sort.each do |path| + errors[gem_name][path] = "Missing file" + end + + good, unreadable = good.partition do |file_name| + File.readable? File.join(gem_directory, file_name) + end + + unreadable.sort.each do |path| + errors[gem_name][path] = "Unreadable file" + end + + good.each do |entry, data| + next unless data # HACK: `gem check -a mkrf` + + source = File.join gem_directory, entry["path"] + + File.open source, Gem.binary_mode do |f| + unless f.read == data + errors[gem_name][entry["path"]] = "Modified from original" + end + end + end + end + + installed_files = find_files_for_gem(gem_directory) + extras = installed_files - good - unreadable + + extras.each do |extra| + errors[gem_name][extra] = "Extra file" + end + rescue Gem::VerificationError => e + errors[gem_name][gem_path] = e.message + end + end + + errors.each do |name, subhash| + errors[name] = subhash.map do |path, msg| + ErrorData.new path, msg + end.sort + end + + errors + end +end diff --git a/lib/rubygems/vendor/.document b/lib/rubygems/vendor/.document new file mode 100644 index 0000000000..0c43bbd6b3 --- /dev/null +++ b/lib/rubygems/vendor/.document @@ -0,0 +1 @@ +# Vendored files do not need to be documented diff --git a/lib/rubygems/vendor/net-http/lib/net/http.rb b/lib/rubygems/vendor/net-http/lib/net/http.rb new file mode 100644 index 0000000000..4800cd25f1 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http.rb @@ -0,0 +1,2608 @@ +# frozen_string_literal: true +# +# = net/http.rb +# +# Copyright (c) 1999-2007 Yukihiro Matsumoto +# Copyright (c) 1999-2007 Minero Aoki +# Copyright (c) 2001 GOTOU Yuuzou +# +# Written and maintained by Minero Aoki <aamine@loveruby.net>. +# HTTPS support added by GOTOU Yuuzou <gotoyuzo@notwork.org>. +# +# This file is derived from "http-access.rb". +# +# Documented by Minero Aoki; converted to RDoc by William Webber. +# +# This program is free software. You can re-distribute and/or +# modify this program under the same terms of ruby itself --- +# Ruby Distribution License or GNU General Public License. +# +# See Gem::Net::HTTP for an overview and examples. +# + +require_relative '../../../net-protocol/lib/net/protocol' +require_relative '../../../uri/lib/uri' +require_relative '../../../resolv/lib/resolv' +autoload :OpenSSL, 'openssl' + +module Gem::Net #:nodoc: + + # :stopdoc: + class HTTPBadResponse < StandardError; end + class HTTPHeaderSyntaxError < StandardError; end + # :startdoc: + + # \Class \Gem::Net::HTTP provides a rich library that implements the client + # in a client-server model that uses the \HTTP request-response protocol. + # For information about \HTTP, see: + # + # - {Hypertext Transfer Protocol}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol]. + # - {Technical overview}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Technical_overview]. + # + # == About the Examples + # + # :include: doc/net-http/examples.rdoc + # + # == Strategies + # + # - If you will make only a few GET requests, + # consider using {OpenURI}[https://docs.ruby-lang.org/en/master/OpenURI.html]. + # - If you will make only a few requests of all kinds, + # consider using the various singleton convenience methods in this class. + # Each of the following methods automatically starts and finishes + # a {session}[rdoc-ref:Gem::Net::HTTP@Sessions] that sends a single request: + # + # # Return string response body. + # Gem::Net::HTTP.get(hostname, path) + # Gem::Net::HTTP.get(uri) + # + # # Write string response body to $stdout. + # Gem::Net::HTTP.get_print(hostname, path) + # Gem::Net::HTTP.get_print(uri) + # + # # Return response as Gem::Net::HTTPResponse object. + # Gem::Net::HTTP.get_response(hostname, path) + # Gem::Net::HTTP.get_response(uri) + # data = '{"title": "foo", "body": "bar", "userId": 1}' + # Gem::Net::HTTP.post(uri, data) + # params = {title: 'foo', body: 'bar', userId: 1} + # Gem::Net::HTTP.post_form(uri, params) + # data = '{"title": "foo", "body": "bar", "userId": 1}' + # Gem::Net::HTTP.put(uri, data) + # + # - If performance is important, consider using sessions, which lower request overhead. + # This {session}[rdoc-ref:Gem::Net::HTTP@Sessions] has multiple requests for + # {HTTP methods}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Request_methods] + # and {WebDAV methods}[https://en.wikipedia.org/wiki/WebDAV#Implementation]: + # + # Gem::Net::HTTP.start(hostname) do |http| + # # Session started automatically before block execution. + # http.get(path) + # http.head(path) + # body = 'Some text' + # http.post(path, body) # Can also have a block. + # http.put(path, body) + # http.delete(path) + # http.options(path) + # http.trace(path) + # http.patch(path, body) # Can also have a block. + # http.copy(path) + # http.lock(path, body) + # http.mkcol(path, body) + # http.move(path) + # http.propfind(path, body) + # http.proppatch(path, body) + # http.unlock(path, body) + # # Session finished automatically at block exit. + # end + # + # The methods cited above are convenience methods that, via their few arguments, + # allow minimal control over the requests. + # For greater control, consider using {request objects}[rdoc-ref:Gem::Net::HTTPRequest]. + # + # == URIs + # + # On the internet, a Gem::URI + # ({Universal Resource Identifier}[https://en.wikipedia.org/wiki/Uniform_Resource_Identifier]) + # is a string that identifies a particular resource. + # It consists of some or all of: scheme, hostname, path, query, and fragment; + # see {Gem::URI syntax}[https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax]. + # + # A Ruby {Gem::URI::Generic}[https://docs.ruby-lang.org/en/master/Gem::URI/Generic.html] object + # represents an internet Gem::URI. + # It provides, among others, methods + # +scheme+, +hostname+, +path+, +query+, and +fragment+. + # + # === Schemes + # + # An internet \Gem::URI has + # a {scheme}[https://en.wikipedia.org/wiki/List_of_URI_schemes]. + # + # The two schemes supported in \Gem::Net::HTTP are <tt>'https'</tt> and <tt>'http'</tt>: + # + # uri.scheme # => "https" + # Gem::URI('http://example.com').scheme # => "http" + # + # === Hostnames + # + # A hostname identifies a server (host) to which requests may be sent: + # + # hostname = uri.hostname # => "jsonplaceholder.typicode.com" + # Gem::Net::HTTP.start(hostname) do |http| + # # Some HTTP stuff. + # end + # + # === Paths + # + # A host-specific path identifies a resource on the host: + # + # _uri = uri.dup + # _uri.path = '/todos/1' + # hostname = _uri.hostname + # path = _uri.path + # Gem::Net::HTTP.get(hostname, path) + # + # === Queries + # + # A host-specific query adds name/value pairs to the Gem::URI: + # + # _uri = uri.dup + # params = {userId: 1, completed: false} + # _uri.query = Gem::URI.encode_www_form(params) + # _uri # => #<Gem::URI::HTTPS https://jsonplaceholder.typicode.com?userId=1&completed=false> + # Gem::Net::HTTP.get(_uri) + # + # === Fragments + # + # A {Gem::URI fragment}[https://en.wikipedia.org/wiki/URI_fragment] has no effect + # in \Gem::Net::HTTP; + # the same data is returned, regardless of whether a fragment is included. + # + # == Request Headers + # + # Request headers may be used to pass additional information to the host, + # similar to arguments passed in a method call; + # each header is a name/value pair. + # + # Each of the \Gem::Net::HTTP methods that sends a request to the host + # has optional argument +headers+, + # where the headers are expressed as a hash of field-name/value pairs: + # + # headers = {Accept: 'application/json', Connection: 'Keep-Alive'} + # Gem::Net::HTTP.get(uri, headers) + # + # See lists of both standard request fields and common request fields at + # {Request Fields}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields]. + # A host may also accept other custom fields. + # + # == \HTTP Sessions + # + # A _session_ is a connection between a server (host) and a client that: + # + # - Is begun by instance method Gem::Net::HTTP#start. + # - May contain any number of requests. + # - Is ended by instance method Gem::Net::HTTP#finish. + # + # See example sessions at {Strategies}[rdoc-ref:Gem::Net::HTTP@Strategies]. + # + # === Session Using \Gem::Net::HTTP.start + # + # If you have many requests to make to a single host (and port), + # consider using singleton method Gem::Net::HTTP.start with a block; + # the method handles the session automatically by: + # + # - Calling #start before block execution. + # - Executing the block. + # - Calling #finish after block execution. + # + # In the block, you can use these instance methods, + # each of which that sends a single request: + # + # - {HTTP methods}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Request_methods]: + # + # - #get, #request_get: GET. + # - #head, #request_head: HEAD. + # - #post, #request_post: POST. + # - #delete: DELETE. + # - #options: OPTIONS. + # - #trace: TRACE. + # - #patch: PATCH. + # + # - {WebDAV methods}[https://en.wikipedia.org/wiki/WebDAV#Implementation]: + # + # - #copy: COPY. + # - #lock: LOCK. + # - #mkcol: MKCOL. + # - #move: MOVE. + # - #propfind: PROPFIND. + # - #proppatch: PROPPATCH. + # - #unlock: UNLOCK. + # + # === Session Using \Gem::Net::HTTP.start and \Gem::Net::HTTP.finish + # + # You can manage a session manually using methods #start and #finish: + # + # http = Gem::Net::HTTP.new(hostname) + # http.start + # http.get('/todos/1') + # http.get('/todos/2') + # http.delete('/posts/1') + # http.finish # Needed to free resources. + # + # === Single-Request Session + # + # Certain convenience methods automatically handle a session by: + # + # - Creating an \HTTP object + # - Starting a session. + # - Sending a single request. + # - Finishing the session. + # - Destroying the object. + # + # Such methods that send GET requests: + # + # - ::get: Returns the string response body. + # - ::get_print: Writes the string response body to $stdout. + # - ::get_response: Returns a Gem::Net::HTTPResponse object. + # + # Such methods that send POST requests: + # + # - ::post: Posts data to the host. + # - ::post_form: Posts form data to the host. + # + # == \HTTP Requests and Responses + # + # Many of the methods above are convenience methods, + # each of which sends a request and returns a string + # without directly using \Gem::Net::HTTPRequest and \Gem::Net::HTTPResponse objects. + # + # You can, however, directly create a request object, send the request, + # and retrieve the response object; see: + # + # - Gem::Net::HTTPRequest. + # - Gem::Net::HTTPResponse. + # + # == Following Redirection + # + # Each returned response is an instance of a subclass of Gem::Net::HTTPResponse. + # See the {response class hierarchy}[rdoc-ref:Gem::Net::HTTPResponse@Response+Subclasses]. + # + # In particular, class Gem::Net::HTTPRedirection is the parent + # of all redirection classes. + # This allows you to craft a case statement to handle redirections properly: + # + # def fetch(uri, limit = 10) + # # You should choose a better exception. + # raise ArgumentError, 'Too many HTTP redirects' if limit == 0 + # + # res = Gem::Net::HTTP.get_response(Gem::URI(uri)) + # case res + # when Gem::Net::HTTPSuccess # Any success class. + # res + # when Gem::Net::HTTPRedirection # Any redirection class. + # location = res['Location'] + # warn "Redirected to #{location}" + # fetch(location, limit - 1) + # else # Any other class. + # res.value + # end + # end + # + # fetch(uri) + # + # == Basic Authentication + # + # Basic authentication is performed according to + # {RFC2617}[http://www.ietf.org/rfc/rfc2617.txt]: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.basic_auth('user', 'pass') + # res = Gem::Net::HTTP.start(hostname) do |http| + # http.request(req) + # end + # + # == Streaming Response Bodies + # + # By default \Gem::Net::HTTP reads an entire response into memory. If you are + # handling large files or wish to implement a progress bar you can instead + # stream the body directly to an IO. + # + # Gem::Net::HTTP.start(hostname) do |http| + # req = Gem::Net::HTTP::Get.new(uri) + # http.request(req) do |res| + # open('t.tmp', 'w') do |f| + # res.read_body do |chunk| + # f.write chunk + # end + # end + # end + # end + # + # == HTTPS + # + # HTTPS is enabled for an \HTTP connection by Gem::Net::HTTP#use_ssl=: + # + # Gem::Net::HTTP.start(hostname, :use_ssl => true) do |http| + # req = Gem::Net::HTTP::Get.new(uri) + # res = http.request(req) + # end + # + # Or if you simply want to make a GET request, you may pass in a Gem::URI + # object that has an \HTTPS URL. \Gem::Net::HTTP automatically turns on TLS + # verification if the Gem::URI object has a 'https' Gem::URI scheme: + # + # uri # => #<Gem::URI::HTTPS https://jsonplaceholder.typicode.com/> + # Gem::Net::HTTP.get(uri) + # + # == Proxy Server + # + # An \HTTP object can have + # a {proxy server}[https://en.wikipedia.org/wiki/Proxy_server]. + # + # You can create an \HTTP object with a proxy server + # using method Gem::Net::HTTP.new or method Gem::Net::HTTP.start. + # + # The proxy may be defined either by argument +p_addr+ + # or by environment variable <tt>'http_proxy'</tt>. + # + # === Proxy Using Argument +p_addr+ as a \String + # + # When argument +p_addr+ is a string hostname, + # the returned +http+ has the given host as its proxy: + # + # http = Gem::Net::HTTP.new(hostname, nil, 'proxy.example') + # http.proxy? # => true + # http.proxy_from_env? # => false + # http.proxy_address # => "proxy.example" + # # These use default values. + # http.proxy_port # => 80 + # http.proxy_user # => nil + # http.proxy_pass # => nil + # + # The port, username, and password for the proxy may also be given: + # + # http = Gem::Net::HTTP.new(hostname, nil, 'proxy.example', 8000, 'pname', 'ppass') + # # => #<Gem::Net::HTTP jsonplaceholder.typicode.com:80 open=false> + # http.proxy? # => true + # http.proxy_from_env? # => false + # http.proxy_address # => "proxy.example" + # http.proxy_port # => 8000 + # http.proxy_user # => "pname" + # http.proxy_pass # => "ppass" + # + # === Proxy Using '<tt>ENV['http_proxy']</tt>' + # + # When environment variable <tt>'http_proxy'</tt> + # is set to a \Gem::URI string, + # the returned +http+ will have the server at that Gem::URI as its proxy; + # note that the \Gem::URI string must have a protocol + # such as <tt>'http'</tt> or <tt>'https'</tt>: + # + # ENV['http_proxy'] = 'http://example.com' + # http = Gem::Net::HTTP.new(hostname) + # http.proxy? # => true + # http.proxy_from_env? # => true + # http.proxy_address # => "example.com" + # # These use default values. + # http.proxy_port # => 80 + # http.proxy_user # => nil + # http.proxy_pass # => nil + # + # The \Gem::URI string may include proxy username, password, and port number: + # + # ENV['http_proxy'] = 'http://pname:ppass@example.com:8000' + # http = Gem::Net::HTTP.new(hostname) + # http.proxy? # => true + # http.proxy_from_env? # => true + # http.proxy_address # => "example.com" + # http.proxy_port # => 8000 + # http.proxy_user # => "pname" + # http.proxy_pass # => "ppass" + # + # === Filtering Proxies + # + # With method Gem::Net::HTTP.new (but not Gem::Net::HTTP.start), + # you can use argument +p_no_proxy+ to filter proxies: + # + # - Reject a certain address: + # + # http = Gem::Net::HTTP.new('example.com', nil, 'proxy.example', 8000, 'pname', 'ppass', 'proxy.example') + # http.proxy_address # => nil + # + # - Reject certain domains or subdomains: + # + # http = Gem::Net::HTTP.new('example.com', nil, 'my.proxy.example', 8000, 'pname', 'ppass', 'proxy.example') + # http.proxy_address # => nil + # + # - Reject certain addresses and port combinations: + # + # http = Gem::Net::HTTP.new('example.com', nil, 'proxy.example', 8000, 'pname', 'ppass', 'proxy.example:1234') + # http.proxy_address # => "proxy.example" + # + # http = Gem::Net::HTTP.new('example.com', nil, 'proxy.example', 8000, 'pname', 'ppass', 'proxy.example:8000') + # http.proxy_address # => nil + # + # - Reject a list of the types above delimited using a comma: + # + # http = Gem::Net::HTTP.new('example.com', nil, 'proxy.example', 8000, 'pname', 'ppass', 'my.proxy,proxy.example:8000') + # http.proxy_address # => nil + # + # http = Gem::Net::HTTP.new('example.com', nil, 'my.proxy', 8000, 'pname', 'ppass', 'my.proxy,proxy.example:8000') + # http.proxy_address # => nil + # + # == Compression and Decompression + # + # \Gem::Net::HTTP does not compress the body of a request before sending. + # + # By default, \Gem::Net::HTTP adds header <tt>'Accept-Encoding'</tt> + # to a new {request object}[rdoc-ref:Gem::Net::HTTPRequest]: + # + # Gem::Net::HTTP::Get.new(uri)['Accept-Encoding'] + # # => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3" + # + # This requests the server to zip-encode the response body if there is one; + # the server is not required to do so. + # + # \Gem::Net::HTTP does not automatically decompress a response body + # if the response has header <tt>'Content-Range'</tt>. + # + # Otherwise decompression (or not) depends on the value of header + # {Content-Encoding}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-encoding-response-header]: + # + # - <tt>'deflate'</tt>, <tt>'gzip'</tt>, or <tt>'x-gzip'</tt>: + # decompresses the body and deletes the header. + # - <tt>'none'</tt> or <tt>'identity'</tt>: + # does not decompress the body, but deletes the header. + # - Any other value: + # leaves the body and header unchanged. + # + # == What's Here + # + # First, what's elsewhere. Class Gem::Net::HTTP: + # + # - Inherits from {class Object}[https://docs.ruby-lang.org/en/master/Object.html#class-Object-label-What-27s+Here]. + # + # This is a categorized summary of methods and attributes. + # + # === \Gem::Net::HTTP Objects + # + # - {::new}[rdoc-ref:Gem::Net::HTTP.new]: + # Creates a new instance. + # - {#inspect}[rdoc-ref:Gem::Net::HTTP#inspect]: + # Returns a string representation of +self+. + # + # === Sessions + # + # - {::start}[rdoc-ref:Gem::Net::HTTP.start]: + # Begins a new session in a new \Gem::Net::HTTP object. + # - {#started?}[rdoc-ref:Gem::Net::HTTP#started?]: + # Returns whether in a session. + # - {#finish}[rdoc-ref:Gem::Net::HTTP#finish]: + # Ends an active session. + # - {#start}[rdoc-ref:Gem::Net::HTTP#start]: + # Begins a new session in an existing \Gem::Net::HTTP object (+self+). + # + # === Connections + # + # - {:continue_timeout}[rdoc-ref:Gem::Net::HTTP#continue_timeout]: + # Returns the continue timeout. + # - {#continue_timeout=}[rdoc-ref:Gem::Net::HTTP#continue_timeout=]: + # Sets the continue timeout seconds. + # - {:keep_alive_timeout}[rdoc-ref:Gem::Net::HTTP#keep_alive_timeout]: + # Returns the keep-alive timeout. + # - {:keep_alive_timeout=}[rdoc-ref:Gem::Net::HTTP#keep_alive_timeout=]: + # Sets the keep-alive timeout. + # - {:max_retries}[rdoc-ref:Gem::Net::HTTP#max_retries]: + # Returns the maximum retries. + # - {#max_retries=}[rdoc-ref:Gem::Net::HTTP#max_retries=]: + # Sets the maximum retries. + # - {:open_timeout}[rdoc-ref:Gem::Net::HTTP#open_timeout]: + # Returns the open timeout. + # - {:open_timeout=}[rdoc-ref:Gem::Net::HTTP#open_timeout=]: + # Sets the open timeout. + # - {:read_timeout}[rdoc-ref:Gem::Net::HTTP#read_timeout]: + # Returns the open timeout. + # - {:read_timeout=}[rdoc-ref:Gem::Net::HTTP#read_timeout=]: + # Sets the read timeout. + # - {:ssl_timeout}[rdoc-ref:Gem::Net::HTTP#ssl_timeout]: + # Returns the ssl timeout. + # - {:ssl_timeout=}[rdoc-ref:Gem::Net::HTTP#ssl_timeout=]: + # Sets the ssl timeout. + # - {:write_timeout}[rdoc-ref:Gem::Net::HTTP#write_timeout]: + # Returns the write timeout. + # - {write_timeout=}[rdoc-ref:Gem::Net::HTTP#write_timeout=]: + # Sets the write timeout. + # + # === Requests + # + # - {::get}[rdoc-ref:Gem::Net::HTTP.get]: + # Sends a GET request and returns the string response body. + # - {::get_print}[rdoc-ref:Gem::Net::HTTP.get_print]: + # Sends a GET request and write the string response body to $stdout. + # - {::get_response}[rdoc-ref:Gem::Net::HTTP.get_response]: + # Sends a GET request and returns a response object. + # - {::post_form}[rdoc-ref:Gem::Net::HTTP.post_form]: + # Sends a POST request with form data and returns a response object. + # - {::post}[rdoc-ref:Gem::Net::HTTP.post]: + # Sends a POST request with data and returns a response object. + # - {::put}[rdoc-ref:Gem::Net::HTTP.put]: + # Sends a PUT request with data and returns a response object. + # - {#copy}[rdoc-ref:Gem::Net::HTTP#copy]: + # Sends a COPY request and returns a response object. + # - {#delete}[rdoc-ref:Gem::Net::HTTP#delete]: + # Sends a DELETE request and returns a response object. + # - {#get}[rdoc-ref:Gem::Net::HTTP#get]: + # Sends a GET request and returns a response object. + # - {#head}[rdoc-ref:Gem::Net::HTTP#head]: + # Sends a HEAD request and returns a response object. + # - {#lock}[rdoc-ref:Gem::Net::HTTP#lock]: + # Sends a LOCK request and returns a response object. + # - {#mkcol}[rdoc-ref:Gem::Net::HTTP#mkcol]: + # Sends a MKCOL request and returns a response object. + # - {#move}[rdoc-ref:Gem::Net::HTTP#move]: + # Sends a MOVE request and returns a response object. + # - {#options}[rdoc-ref:Gem::Net::HTTP#options]: + # Sends a OPTIONS request and returns a response object. + # - {#patch}[rdoc-ref:Gem::Net::HTTP#patch]: + # Sends a PATCH request and returns a response object. + # - {#post}[rdoc-ref:Gem::Net::HTTP#post]: + # Sends a POST request and returns a response object. + # - {#propfind}[rdoc-ref:Gem::Net::HTTP#propfind]: + # Sends a PROPFIND request and returns a response object. + # - {#proppatch}[rdoc-ref:Gem::Net::HTTP#proppatch]: + # Sends a PROPPATCH request and returns a response object. + # - {#put}[rdoc-ref:Gem::Net::HTTP#put]: + # Sends a PUT request and returns a response object. + # - {#request}[rdoc-ref:Gem::Net::HTTP#request]: + # Sends a request and returns a response object. + # - {#request_get}[rdoc-ref:Gem::Net::HTTP#request_get]: + # Sends a GET request and forms a response object; + # if a block given, calls the block with the object, + # otherwise returns the object. + # - {#request_head}[rdoc-ref:Gem::Net::HTTP#request_head]: + # Sends a HEAD request and forms a response object; + # if a block given, calls the block with the object, + # otherwise returns the object. + # - {#request_post}[rdoc-ref:Gem::Net::HTTP#request_post]: + # Sends a POST request and forms a response object; + # if a block given, calls the block with the object, + # otherwise returns the object. + # - {#send_request}[rdoc-ref:Gem::Net::HTTP#send_request]: + # Sends a request and returns a response object. + # - {#trace}[rdoc-ref:Gem::Net::HTTP#trace]: + # Sends a TRACE request and returns a response object. + # - {#unlock}[rdoc-ref:Gem::Net::HTTP#unlock]: + # Sends an UNLOCK request and returns a response object. + # + # === Responses + # + # - {:close_on_empty_response}[rdoc-ref:Gem::Net::HTTP#close_on_empty_response]: + # Returns whether to close connection on empty response. + # - {:close_on_empty_response=}[rdoc-ref:Gem::Net::HTTP#close_on_empty_response=]: + # Sets whether to close connection on empty response. + # - {:ignore_eof}[rdoc-ref:Gem::Net::HTTP#ignore_eof]: + # Returns whether to ignore end-of-file when reading a response body + # with <tt>Content-Length</tt> headers. + # - {:ignore_eof=}[rdoc-ref:Gem::Net::HTTP#ignore_eof=]: + # Sets whether to ignore end-of-file when reading a response body + # with <tt>Content-Length</tt> headers. + # - {:response_body_encoding}[rdoc-ref:Gem::Net::HTTP#response_body_encoding]: + # Returns the encoding to use for the response body. + # - {#response_body_encoding=}[rdoc-ref:Gem::Net::HTTP#response_body_encoding=]: + # Sets the response body encoding. + # + # === Proxies + # + # - {:proxy_address}[rdoc-ref:Gem::Net::HTTP#proxy_address]: + # Returns the proxy address. + # - {:proxy_address=}[rdoc-ref:Gem::Net::HTTP#proxy_address=]: + # Sets the proxy address. + # - {::proxy_class?}[rdoc-ref:Gem::Net::HTTP.proxy_class?]: + # Returns whether +self+ is a proxy class. + # - {#proxy?}[rdoc-ref:Gem::Net::HTTP#proxy?]: + # Returns whether +self+ has a proxy. + # - {#proxy_address}[rdoc-ref:Gem::Net::HTTP#proxy_address]: + # Returns the proxy address. + # - {#proxy_from_env?}[rdoc-ref:Gem::Net::HTTP#proxy_from_env?]: + # Returns whether the proxy is taken from an environment variable. + # - {:proxy_from_env=}[rdoc-ref:Gem::Net::HTTP#proxy_from_env=]: + # Sets whether the proxy is to be taken from an environment variable. + # - {:proxy_pass}[rdoc-ref:Gem::Net::HTTP#proxy_pass]: + # Returns the proxy password. + # - {:proxy_pass=}[rdoc-ref:Gem::Net::HTTP#proxy_pass=]: + # Sets the proxy password. + # - {:proxy_port}[rdoc-ref:Gem::Net::HTTP#proxy_port]: + # Returns the proxy port. + # - {:proxy_port=}[rdoc-ref:Gem::Net::HTTP#proxy_port=]: + # Sets the proxy port. + # - {#proxy_user}[rdoc-ref:Gem::Net::HTTP#proxy_user]: + # Returns the proxy user name. + # - {:proxy_user=}[rdoc-ref:Gem::Net::HTTP#proxy_user=]: + # Sets the proxy user. + # + # === Security + # + # - {:ca_file}[rdoc-ref:Gem::Net::HTTP#ca_file]: + # Returns the path to a CA certification file. + # - {:ca_file=}[rdoc-ref:Gem::Net::HTTP#ca_file=]: + # Sets the path to a CA certification file. + # - {:ca_path}[rdoc-ref:Gem::Net::HTTP#ca_path]: + # Returns the path of to CA directory containing certification files. + # - {:ca_path=}[rdoc-ref:Gem::Net::HTTP#ca_path=]: + # Sets the path of to CA directory containing certification files. + # - {:cert}[rdoc-ref:Gem::Net::HTTP#cert]: + # Returns the OpenSSL::X509::Certificate object to be used for client certification. + # - {:cert=}[rdoc-ref:Gem::Net::HTTP#cert=]: + # Sets the OpenSSL::X509::Certificate object to be used for client certification. + # - {:cert_store}[rdoc-ref:Gem::Net::HTTP#cert_store]: + # Returns the X509::Store to be used for verifying peer certificate. + # - {:cert_store=}[rdoc-ref:Gem::Net::HTTP#cert_store=]: + # Sets the X509::Store to be used for verifying peer certificate. + # - {:ciphers}[rdoc-ref:Gem::Net::HTTP#ciphers]: + # Returns the available SSL ciphers. + # - {:ciphers=}[rdoc-ref:Gem::Net::HTTP#ciphers=]: + # Sets the available SSL ciphers. + # - {:extra_chain_cert}[rdoc-ref:Gem::Net::HTTP#extra_chain_cert]: + # Returns the extra X509 certificates to be added to the certificate chain. + # - {:extra_chain_cert=}[rdoc-ref:Gem::Net::HTTP#extra_chain_cert=]: + # Sets the extra X509 certificates to be added to the certificate chain. + # - {:key}[rdoc-ref:Gem::Net::HTTP#key]: + # Returns the OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object. + # - {:key=}[rdoc-ref:Gem::Net::HTTP#key=]: + # Sets the OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object. + # - {:max_version}[rdoc-ref:Gem::Net::HTTP#max_version]: + # Returns the maximum SSL version. + # - {:max_version=}[rdoc-ref:Gem::Net::HTTP#max_version=]: + # Sets the maximum SSL version. + # - {:min_version}[rdoc-ref:Gem::Net::HTTP#min_version]: + # Returns the minimum SSL version. + # - {:min_version=}[rdoc-ref:Gem::Net::HTTP#min_version=]: + # Sets the minimum SSL version. + # - {#peer_cert}[rdoc-ref:Gem::Net::HTTP#peer_cert]: + # Returns the X509 certificate chain for the session's socket peer. + # - {:ssl_version}[rdoc-ref:Gem::Net::HTTP#ssl_version]: + # Returns the SSL version. + # - {:ssl_version=}[rdoc-ref:Gem::Net::HTTP#ssl_version=]: + # Sets the SSL version. + # - {#use_ssl=}[rdoc-ref:Gem::Net::HTTP#use_ssl=]: + # Sets whether a new session is to use Transport Layer Security. + # - {#use_ssl?}[rdoc-ref:Gem::Net::HTTP#use_ssl?]: + # Returns whether +self+ uses SSL. + # - {:verify_callback}[rdoc-ref:Gem::Net::HTTP#verify_callback]: + # Returns the callback for the server certification verification. + # - {:verify_callback=}[rdoc-ref:Gem::Net::HTTP#verify_callback=]: + # Sets the callback for the server certification verification. + # - {:verify_depth}[rdoc-ref:Gem::Net::HTTP#verify_depth]: + # Returns the maximum depth for the certificate chain verification. + # - {:verify_depth=}[rdoc-ref:Gem::Net::HTTP#verify_depth=]: + # Sets the maximum depth for the certificate chain verification. + # - {:verify_hostname}[rdoc-ref:Gem::Net::HTTP#verify_hostname]: + # Returns the flags for server the certification verification at the beginning of the SSL/TLS session. + # - {:verify_hostname=}[rdoc-ref:Gem::Net::HTTP#verify_hostname=]: + # Sets he flags for server the certification verification at the beginning of the SSL/TLS session. + # - {:verify_mode}[rdoc-ref:Gem::Net::HTTP#verify_mode]: + # Returns the flags for server the certification verification at the beginning of the SSL/TLS session. + # - {:verify_mode=}[rdoc-ref:Gem::Net::HTTP#verify_mode=]: + # Sets the flags for server the certification verification at the beginning of the SSL/TLS session. + # + # === Addresses and Ports + # + # - {:address}[rdoc-ref:Gem::Net::HTTP#address]: + # Returns the string host name or host IP. + # - {::default_port}[rdoc-ref:Gem::Net::HTTP.default_port]: + # Returns integer 80, the default port to use for HTTP requests. + # - {::http_default_port}[rdoc-ref:Gem::Net::HTTP.http_default_port]: + # Returns integer 80, the default port to use for HTTP requests. + # - {::https_default_port}[rdoc-ref:Gem::Net::HTTP.https_default_port]: + # Returns integer 443, the default port to use for HTTPS requests. + # - {#ipaddr}[rdoc-ref:Gem::Net::HTTP#ipaddr]: + # Returns the IP address for the connection. + # - {#ipaddr=}[rdoc-ref:Gem::Net::HTTP#ipaddr=]: + # Sets the IP address for the connection. + # - {:local_host}[rdoc-ref:Gem::Net::HTTP#local_host]: + # Returns the string local host used to establish the connection. + # - {:local_host=}[rdoc-ref:Gem::Net::HTTP#local_host=]: + # Sets the string local host used to establish the connection. + # - {:local_port}[rdoc-ref:Gem::Net::HTTP#local_port]: + # Returns the integer local port used to establish the connection. + # - {:local_port=}[rdoc-ref:Gem::Net::HTTP#local_port=]: + # Sets the integer local port used to establish the connection. + # - {:port}[rdoc-ref:Gem::Net::HTTP#port]: + # Returns the integer port number. + # + # === \HTTP Version + # + # - {::version_1_2?}[rdoc-ref:Gem::Net::HTTP.version_1_2?] + # (aliased as {::version_1_2}[rdoc-ref:Gem::Net::HTTP.version_1_2]): + # Returns true; retained for compatibility. + # + # === Debugging + # + # - {#set_debug_output}[rdoc-ref:Gem::Net::HTTP#set_debug_output]: + # Sets the output stream for debugging. + # + class HTTP < Protocol + + # :stopdoc: + VERSION = "0.9.1" + HTTPVersion = '1.1' + begin + require 'zlib' + HAVE_ZLIB=true + rescue LoadError + HAVE_ZLIB=false + end + # :startdoc: + + # Returns +true+; retained for compatibility. + def HTTP.version_1_2 + true + end + + # Returns +true+; retained for compatibility. + def HTTP.version_1_2? + true + end + + # Returns +false+; retained for compatibility. + def HTTP.version_1_1? #:nodoc: + false + end + + class << HTTP + alias is_version_1_1? version_1_1? #:nodoc: + alias is_version_1_2? version_1_2? #:nodoc: + end + + # :call-seq: + # Gem::Net::HTTP.get_print(hostname, path, port = 80) -> nil + # Gem::Net::HTTP:get_print(uri, headers = {}, port = uri.port) -> nil + # + # Like Gem::Net::HTTP.get, but writes the returned body to $stdout; + # returns +nil+. + def HTTP.get_print(uri_or_host, path_or_headers = nil, port = nil) + get_response(uri_or_host, path_or_headers, port) {|res| + res.read_body do |chunk| + $stdout.print chunk + end + } + nil + end + + # :call-seq: + # Gem::Net::HTTP.get(hostname, path, port = 80) -> body + # Gem::Net::HTTP:get(uri, headers = {}, port = uri.port) -> body + # + # Sends a GET request and returns the \HTTP response body as a string. + # + # With string arguments +hostname+ and +path+: + # + # hostname = 'jsonplaceholder.typicode.com' + # path = '/todos/1' + # puts Gem::Net::HTTP.get(hostname, path) + # + # Output: + # + # { + # "userId": 1, + # "id": 1, + # "title": "delectus aut autem", + # "completed": false + # } + # + # With Gem::URI object +uri+ and optional hash argument +headers+: + # + # uri = Gem::URI('https://jsonplaceholder.typicode.com/todos/1') + # headers = {'Content-type' => 'application/json; charset=UTF-8'} + # Gem::Net::HTTP.get(uri, headers) + # + # Related: + # + # - Gem::Net::HTTP::Get: request class for \HTTP method +GET+. + # - Gem::Net::HTTP#get: convenience method for \HTTP method +GET+. + # + def HTTP.get(uri_or_host, path_or_headers = nil, port = nil) + get_response(uri_or_host, path_or_headers, port).body + end + + # :call-seq: + # Gem::Net::HTTP.get_response(hostname, path, port = 80) -> http_response + # Gem::Net::HTTP:get_response(uri, headers = {}, port = uri.port) -> http_response + # + # Like Gem::Net::HTTP.get, but returns a Gem::Net::HTTPResponse object + # instead of the body string. + def HTTP.get_response(uri_or_host, path_or_headers = nil, port = nil, &block) + if path_or_headers && !path_or_headers.is_a?(Hash) + host = uri_or_host + path = path_or_headers + new(host, port || HTTP.default_port).start {|http| + return http.request_get(path, &block) + } + else + uri = uri_or_host + headers = path_or_headers + start(uri.hostname, uri.port, + :use_ssl => uri.scheme == 'https') {|http| + return http.request_get(uri, headers, &block) + } + end + end + + # Posts data to a host; returns a Gem::Net::HTTPResponse object. + # + # Argument +url+ must be a URL; + # argument +data+ must be a string: + # + # _uri = uri.dup + # _uri.path = '/posts' + # data = '{"title": "foo", "body": "bar", "userId": 1}' + # headers = {'content-type': 'application/json'} + # res = Gem::Net::HTTP.post(_uri, data, headers) # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # puts res.body + # + # Output: + # + # { + # "title": "foo", + # "body": "bar", + # "userId": 1, + # "id": 101 + # } + # + # Related: + # + # - Gem::Net::HTTP::Post: request class for \HTTP method +POST+. + # - Gem::Net::HTTP#post: convenience method for \HTTP method +POST+. + # + def HTTP.post(url, data, header = nil) + start(url.hostname, url.port, + :use_ssl => url.scheme == 'https' ) {|http| + http.post(url, data, header) + } + end + + # Posts data to a host; returns a Gem::Net::HTTPResponse object. + # + # Argument +url+ must be a Gem::URI; + # argument +data+ must be a hash: + # + # _uri = uri.dup + # _uri.path = '/posts' + # data = {title: 'foo', body: 'bar', userId: 1} + # res = Gem::Net::HTTP.post_form(_uri, data) # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # puts res.body + # + # Output: + # + # { + # "title": "foo", + # "body": "bar", + # "userId": "1", + # "id": 101 + # } + # + def HTTP.post_form(url, params) + req = Post.new(url) + req.form_data = params + req.basic_auth url.user, url.password if url.user + start(url.hostname, url.port, + :use_ssl => url.scheme == 'https' ) {|http| + http.request(req) + } + end + + # Sends a PUT request to the server; returns a Gem::Net::HTTPResponse object. + # + # Argument +url+ must be a URL; + # argument +data+ must be a string: + # + # _uri = uri.dup + # _uri.path = '/posts' + # data = '{"title": "foo", "body": "bar", "userId": 1}' + # headers = {'content-type': 'application/json'} + # res = Gem::Net::HTTP.put(_uri, data, headers) # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # puts res.body + # + # Output: + # + # { + # "title": "foo", + # "body": "bar", + # "userId": 1, + # "id": 101 + # } + # + # Related: + # + # - Gem::Net::HTTP::Put: request class for \HTTP method +PUT+. + # - Gem::Net::HTTP#put: convenience method for \HTTP method +PUT+. + # + def HTTP.put(url, data, header = nil) + start(url.hostname, url.port, + :use_ssl => url.scheme == 'https' ) {|http| + http.put(url, data, header) + } + end + + # + # \HTTP session management + # + + # Returns integer +80+, the default port to use for \HTTP requests: + # + # Gem::Net::HTTP.default_port # => 80 + # + def HTTP.default_port + http_default_port() + end + + # Returns integer +80+, the default port to use for \HTTP requests: + # + # Gem::Net::HTTP.http_default_port # => 80 + # + def HTTP.http_default_port + 80 + end + + # Returns integer +443+, the default port to use for HTTPS requests: + # + # Gem::Net::HTTP.https_default_port # => 443 + # + def HTTP.https_default_port + 443 + end + + def HTTP.socket_type #:nodoc: obsolete + BufferedIO + end + + # :call-seq: + # HTTP.start(address, port = nil, p_addr = :ENV, p_port = nil, p_user = nil, p_pass = nil, opts) -> http + # HTTP.start(address, port = nil, p_addr = :ENV, p_port = nil, p_user = nil, p_pass = nil, opts) {|http| ... } -> object + # + # Creates a new \Gem::Net::HTTP object, +http+, via \Gem::Net::HTTP.new: + # + # - For arguments +address+ and +port+, see Gem::Net::HTTP.new. + # - For proxy-defining arguments +p_addr+ through +p_pass+, + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + # - For argument +opts+, see below. + # + # With no block given: + # + # - Calls <tt>http.start</tt> with no block (see #start), + # which opens a TCP connection and \HTTP session. + # - Returns +http+. + # - The caller should call #finish to close the session: + # + # http = Gem::Net::HTTP.start(hostname) + # http.started? # => true + # http.finish + # http.started? # => false + # + # With a block given: + # + # - Calls <tt>http.start</tt> with the block (see #start), which: + # + # - Opens a TCP connection and \HTTP session. + # - Calls the block, + # which may make any number of requests to the host. + # - Closes the \HTTP session and TCP connection on block exit. + # - Returns the block's value +object+. + # + # - Returns +object+. + # + # Example: + # + # hostname = 'jsonplaceholder.typicode.com' + # Gem::Net::HTTP.start(hostname) do |http| + # puts http.get('/todos/1').body + # puts http.get('/todos/2').body + # end + # + # Output: + # + # { + # "userId": 1, + # "id": 1, + # "title": "delectus aut autem", + # "completed": false + # } + # { + # "userId": 1, + # "id": 2, + # "title": "quis ut nam facilis et officia qui", + # "completed": false + # } + # + # If the last argument given is a hash, it is the +opts+ hash, + # where each key is a method or accessor to be called, + # and its value is the value to be set. + # + # The keys may include: + # + # - #ca_file + # - #ca_path + # - #cert + # - #cert_store + # - #ciphers + # - #close_on_empty_response + # - +ipaddr+ (calls #ipaddr=) + # - #keep_alive_timeout + # - #key + # - #open_timeout + # - #read_timeout + # - #ssl_timeout + # - #ssl_version + # - +use_ssl+ (calls #use_ssl=) + # - #verify_callback + # - #verify_depth + # - #verify_mode + # - #write_timeout + # + # Note: If +port+ is +nil+ and <tt>opts[:use_ssl]</tt> is a truthy value, + # the value passed to +new+ is Gem::Net::HTTP.https_default_port, not +port+. + # + def HTTP.start(address, *arg, &block) # :yield: +http+ + arg.pop if opt = Hash.try_convert(arg[-1]) + port, p_addr, p_port, p_user, p_pass = *arg + p_addr = :ENV if arg.size < 2 + port = https_default_port if !port && opt && opt[:use_ssl] + http = new(address, port, p_addr, p_port, p_user, p_pass) + http.ipaddr = opt[:ipaddr] if opt && opt[:ipaddr] + + if opt + if opt[:use_ssl] + opt = {verify_mode: OpenSSL::SSL::VERIFY_PEER}.update(opt) + end + http.methods.grep(/\A(\w+)=\z/) do |meth| + key = $1.to_sym + opt.key?(key) or next + http.__send__(meth, opt[key]) + end + end + + http.start(&block) + end + + class << HTTP + alias newobj new # :nodoc: + end + + # Returns a new \Gem::Net::HTTP object +http+ + # (but does not open a TCP connection or \HTTP session). + # + # With only string argument +address+ given + # (and <tt>ENV['http_proxy']</tt> undefined or +nil+), + # the returned +http+: + # + # - Has the given address. + # - Has the default port number, Gem::Net::HTTP.default_port (80). + # - Has no proxy. + # + # Example: + # + # http = Gem::Net::HTTP.new(hostname) + # # => #<Gem::Net::HTTP jsonplaceholder.typicode.com:80 open=false> + # http.address # => "jsonplaceholder.typicode.com" + # http.port # => 80 + # http.proxy? # => false + # + # With integer argument +port+ also given, + # the returned +http+ has the given port: + # + # http = Gem::Net::HTTP.new(hostname, 8000) + # # => #<Gem::Net::HTTP jsonplaceholder.typicode.com:8000 open=false> + # http.port # => 8000 + # + # For proxy-defining arguments +p_addr+ through +p_no_proxy+, + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + # + def HTTP.new(address, port = nil, p_addr = :ENV, p_port = nil, p_user = nil, p_pass = nil, p_no_proxy = nil, p_use_ssl = nil) + http = super address, port + + if proxy_class? then # from Gem::Net::HTTP::Proxy() + http.proxy_from_env = @proxy_from_env + http.proxy_address = @proxy_address + http.proxy_port = @proxy_port + http.proxy_user = @proxy_user + http.proxy_pass = @proxy_pass + http.proxy_use_ssl = @proxy_use_ssl + elsif p_addr == :ENV then + http.proxy_from_env = true + else + if p_addr && p_no_proxy && !Gem::URI::Generic.use_proxy?(address, address, port, p_no_proxy) + p_addr = nil + p_port = nil + end + http.proxy_address = p_addr + http.proxy_port = p_port || default_port + http.proxy_user = p_user + http.proxy_pass = p_pass + http.proxy_use_ssl = p_use_ssl + end + + http + end + + class << HTTP + # Allows to set the default configuration that will be used + # when creating a new connection. + # + # Example: + # + # Gem::Net::HTTP.default_configuration = { + # read_timeout: 1, + # write_timeout: 1 + # } + # http = Gem::Net::HTTP.new(hostname) + # http.open_timeout # => 60 + # http.read_timeout # => 1 + # http.write_timeout # => 1 + # + attr_accessor :default_configuration + end + + # Creates a new \Gem::Net::HTTP object for the specified server address, + # without opening the TCP connection or initializing the \HTTP session. + # The +address+ should be a DNS hostname or IP address. + def initialize(address, port = nil) # :nodoc: + defaults = { + keep_alive_timeout: 2, + close_on_empty_response: false, + open_timeout: 60, + read_timeout: 60, + write_timeout: 60, + continue_timeout: nil, + max_retries: 1, + debug_output: nil, + response_body_encoding: false, + ignore_eof: true + } + options = defaults.merge(self.class.default_configuration || {}) + + @address = address + @port = (port || HTTP.default_port) + @ipaddr = nil + @local_host = nil + @local_port = nil + @curr_http_version = HTTPVersion + @keep_alive_timeout = options[:keep_alive_timeout] + @last_communicated = nil + @close_on_empty_response = options[:close_on_empty_response] + @socket = nil + @started = false + @open_timeout = options[:open_timeout] + @read_timeout = options[:read_timeout] + @write_timeout = options[:write_timeout] + @continue_timeout = options[:continue_timeout] + @max_retries = options[:max_retries] + @debug_output = options[:debug_output] + @response_body_encoding = options[:response_body_encoding] + @ignore_eof = options[:ignore_eof] + @tcpsocket_supports_open_timeout = nil + + @proxy_from_env = false + @proxy_uri = nil + @proxy_address = nil + @proxy_port = nil + @proxy_user = nil + @proxy_pass = nil + @proxy_use_ssl = nil + + @use_ssl = false + @ssl_context = nil + @ssl_session = nil + @sspi_enabled = false + SSL_IVNAMES.each do |ivname| + instance_variable_set ivname, nil + end + end + + # Returns a string representation of +self+: + # + # Gem::Net::HTTP.new(hostname).inspect + # # => "#<Gem::Net::HTTP jsonplaceholder.typicode.com:80 open=false>" + # + def inspect + "#<#{self.class} #{@address}:#{@port} open=#{started?}>" + end + + # *WARNING* This method opens a serious security hole. + # Never use this method in production code. + # + # Sets the output stream for debugging: + # + # http = Gem::Net::HTTP.new(hostname) + # File.open('t.tmp', 'w') do |file| + # http.set_debug_output(file) + # http.start + # http.get('/nosuch/1') + # http.finish + # end + # puts File.read('t.tmp') + # + # Output: + # + # opening connection to jsonplaceholder.typicode.com:80... + # opened + # <- "GET /nosuch/1 HTTP/1.1\r\nAccept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\nAccept: */*\r\nUser-Agent: Ruby\r\nHost: jsonplaceholder.typicode.com\r\n\r\n" + # -> "HTTP/1.1 404 Not Found\r\n" + # -> "Date: Mon, 12 Dec 2022 21:14:11 GMT\r\n" + # -> "Content-Type: application/json; charset=utf-8\r\n" + # -> "Content-Length: 2\r\n" + # -> "Connection: keep-alive\r\n" + # -> "X-Powered-By: Express\r\n" + # -> "X-Ratelimit-Limit: 1000\r\n" + # -> "X-Ratelimit-Remaining: 999\r\n" + # -> "X-Ratelimit-Reset: 1670879660\r\n" + # -> "Vary: Origin, Accept-Encoding\r\n" + # -> "Access-Control-Allow-Credentials: true\r\n" + # -> "Cache-Control: max-age=43200\r\n" + # -> "Pragma: no-cache\r\n" + # -> "Expires: -1\r\n" + # -> "X-Content-Type-Options: nosniff\r\n" + # -> "Etag: W/\"2-vyGp6PvFo4RvsFtPoIWeCReyIC8\"\r\n" + # -> "Via: 1.1 vegur\r\n" + # -> "CF-Cache-Status: MISS\r\n" + # -> "Server-Timing: cf-q-config;dur=1.3000000762986e-05\r\n" + # -> "Report-To: {\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v3?s=yOr40jo%2BwS1KHzhTlVpl54beJ5Wx2FcG4gGV0XVrh3X9OlR5q4drUn2dkt5DGO4GDcE%2BVXT7CNgJvGs%2BZleIyMu8CLieFiDIvOviOY3EhHg94m0ZNZgrEdpKD0S85S507l1vsEwEHkoTm%2Ff19SiO\"}],\"group\":\"cf-nel\",\"max_age\":604800}\r\n" + # -> "NEL: {\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}\r\n" + # -> "Server: cloudflare\r\n" + # -> "CF-RAY: 778977dc484ce591-DFW\r\n" + # -> "alt-svc: h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400\r\n" + # -> "\r\n" + # reading 2 bytes... + # -> "{}" + # read 2 bytes + # Conn keep-alive + # + def set_debug_output(output) + warn 'Gem::Net::HTTP#set_debug_output called after HTTP started', uplevel: 1 if started? + @debug_output = output + end + + # Returns the string host name or host IP given as argument +address+ in ::new. + attr_reader :address + + # Returns the integer port number given as argument +port+ in ::new. + attr_reader :port + + # Sets or returns the string local host used to establish the connection; + # initially +nil+. + attr_accessor :local_host + + # Sets or returns the integer local port used to establish the connection; + # initially +nil+. + attr_accessor :local_port + + # Returns the encoding to use for the response body; + # see #response_body_encoding=. + attr_reader :response_body_encoding + + # Sets the encoding to be used for the response body; + # returns the encoding. + # + # The given +value+ may be: + # + # - An Encoding object. + # - The name of an encoding. + # - An alias for an encoding name. + # + # See {Encoding}[https://docs.ruby-lang.org/en/master/Encoding.html]. + # + # Examples: + # + # http = Gem::Net::HTTP.new(hostname) + # http.response_body_encoding = Encoding::US_ASCII # => #<Encoding:US-ASCII> + # http.response_body_encoding = 'US-ASCII' # => "US-ASCII" + # http.response_body_encoding = 'ASCII' # => "ASCII" + # + def response_body_encoding=(value) + value = Encoding.find(value) if value.is_a?(String) + @response_body_encoding = value + end + + # Sets whether to determine the proxy from environment variable + # '<tt>ENV['http_proxy']</tt>'; + # see {Proxy Using ENV['http_proxy']}[rdoc-ref:Gem::Net::HTTP@Proxy+Using+-27ENV-5B-27http_proxy-27-5D-27]. + attr_writer :proxy_from_env + + # Sets the proxy address; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + attr_writer :proxy_address + + # Sets the proxy port; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + attr_writer :proxy_port + + # Sets the proxy user; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + attr_writer :proxy_user + + # Sets the proxy password; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + attr_writer :proxy_pass + + # Sets whether the proxy uses SSL; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + attr_writer :proxy_use_ssl + + # Returns the IP address for the connection. + # + # If the session has not been started, + # returns the value set by #ipaddr=, + # or +nil+ if it has not been set: + # + # http = Gem::Net::HTTP.new(hostname) + # http.ipaddr # => nil + # http.ipaddr = '172.67.155.76' + # http.ipaddr # => "172.67.155.76" + # + # If the session has been started, + # returns the IP address from the socket: + # + # http = Gem::Net::HTTP.new(hostname) + # http.start + # http.ipaddr # => "172.67.155.76" + # http.finish + # + def ipaddr + started? ? @socket.io.peeraddr[3] : @ipaddr + end + + # Sets the IP address for the connection: + # + # http = Gem::Net::HTTP.new(hostname) + # http.ipaddr # => nil + # http.ipaddr = '172.67.155.76' + # http.ipaddr # => "172.67.155.76" + # + # The IP address may not be set if the session has been started. + def ipaddr=(addr) + raise IOError, "ipaddr value changed, but session already started" if started? + @ipaddr = addr + end + + # Sets or returns the numeric (\Integer or \Float) number of seconds + # to wait for a connection to open; + # initially 60. + # If the connection is not made in the given interval, + # an exception is raised. + attr_accessor :open_timeout + + # Returns the numeric (\Integer or \Float) number of seconds + # to wait for one block to be read (via one read(2) call); + # see #read_timeout=. + attr_reader :read_timeout + + # Returns the numeric (\Integer or \Float) number of seconds + # to wait for one block to be written (via one write(2) call); + # see #write_timeout=. + attr_reader :write_timeout + + # Sets the maximum number of times to retry an idempotent request in case of + # \Gem::Net::ReadTimeout, IOError, EOFError, Errno::ECONNRESET, + # Errno::ECONNABORTED, Errno::EPIPE, OpenSSL::SSL::SSLError, + # Gem::Timeout::Error. + # The initial value is 1. + # + # Argument +retries+ must be a non-negative numeric value: + # + # http = Gem::Net::HTTP.new(hostname) + # http.max_retries = 2 # => 2 + # http.max_retries # => 2 + # + def max_retries=(retries) + retries = retries.to_int + if retries < 0 + raise ArgumentError, 'max_retries should be non-negative integer number' + end + @max_retries = retries + end + + # Returns the maximum number of times to retry an idempotent request; + # see #max_retries=. + attr_reader :max_retries + + # Sets the read timeout, in seconds, for +self+ to integer +sec+; + # the initial value is 60. + # + # Argument +sec+ must be a non-negative numeric value: + # + # http = Gem::Net::HTTP.new(hostname) + # http.read_timeout # => 60 + # http.get('/todos/1') # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # http.read_timeout = 0 + # http.get('/todos/1') # Raises Gem::Net::ReadTimeout. + # + def read_timeout=(sec) + @socket.read_timeout = sec if @socket + @read_timeout = sec + end + + # Sets the write timeout, in seconds, for +self+ to integer +sec+; + # the initial value is 60. + # + # Argument +sec+ must be a non-negative numeric value: + # + # _uri = uri.dup + # _uri.path = '/posts' + # body = 'bar' * 200000 + # data = <<EOF + # {"title": "foo", "body": "#{body}", "userId": "1"} + # EOF + # headers = {'content-type': 'application/json'} + # http = Gem::Net::HTTP.new(hostname) + # http.write_timeout # => 60 + # http.post(_uri.path, data, headers) + # # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # http.write_timeout = 0 + # http.post(_uri.path, data, headers) # Raises Gem::Net::WriteTimeout. + # + def write_timeout=(sec) + @socket.write_timeout = sec if @socket + @write_timeout = sec + end + + # Returns the continue timeout value; + # see continue_timeout=. + attr_reader :continue_timeout + + # Sets the continue timeout value, + # which is the number of seconds to wait for an expected 100 Continue response. + # If the \HTTP object does not receive a response in this many seconds + # it sends the request body. + def continue_timeout=(sec) + @socket.continue_timeout = sec if @socket + @continue_timeout = sec + end + + # Sets or returns the numeric (\Integer or \Float) number of seconds + # to keep the connection open after a request is sent; + # initially 2. + # If a new request is made during the given interval, + # the still-open connection is used; + # otherwise the connection will have been closed + # and a new connection is opened. + attr_accessor :keep_alive_timeout + + # Sets or returns whether to ignore end-of-file when reading a response body + # with <tt>Content-Length</tt> headers; + # initially +true+. + attr_accessor :ignore_eof + + # Returns +true+ if the \HTTP session has been started: + # + # http = Gem::Net::HTTP.new(hostname) + # http.started? # => false + # http.start + # http.started? # => true + # http.finish # => nil + # http.started? # => false + # + # Gem::Net::HTTP.start(hostname) do |http| + # http.started? + # end # => true + # http.started? # => false + # + def started? + @started + end + + alias active? started? #:nodoc: obsolete + + # Sets or returns whether to close the connection when the response is empty; + # initially +false+. + attr_accessor :close_on_empty_response + + # Returns +true+ if +self+ uses SSL, +false+ otherwise. + # See Gem::Net::HTTP#use_ssl=. + def use_ssl? + @use_ssl + end + + # Sets whether a new session is to use + # {Transport Layer Security}[https://en.wikipedia.org/wiki/Transport_Layer_Security]: + # + # Raises IOError if attempting to change during a session. + # + # Raises OpenSSL::SSL::SSLError if the port is not an HTTPS port. + def use_ssl=(flag) + flag = flag ? true : false + if started? and @use_ssl != flag + raise IOError, "use_ssl value changed, but session already started" + end + @use_ssl = flag + end + + SSL_ATTRIBUTES = [ + :ca_file, + :ca_path, + :cert, + :cert_store, + :ciphers, + :extra_chain_cert, + :key, + :ssl_timeout, + :ssl_version, + :min_version, + :max_version, + :verify_callback, + :verify_depth, + :verify_mode, + :verify_hostname, + ].freeze # :nodoc: + + SSL_IVNAMES = SSL_ATTRIBUTES.map { |a| "@#{a}".to_sym }.freeze # :nodoc: + + # Sets or returns the path to a CA certification file in PEM format. + attr_accessor :ca_file + + # Sets or returns the path of to CA directory + # containing certification files in PEM format. + attr_accessor :ca_path + + # Sets or returns the OpenSSL::X509::Certificate object + # to be used for client certification. + attr_accessor :cert + + # Sets or returns the X509::Store to be used for verifying peer certificate. + attr_accessor :cert_store + + # Sets or returns the available SSL ciphers. + # See {OpenSSL::SSL::SSLContext#ciphers=}[OpenSSL::SSL::SSL::Context#ciphers=]. + attr_accessor :ciphers + + # Sets or returns the extra X509 certificates to be added to the certificate chain. + # See {OpenSSL::SSL::SSLContext#add_certificate}[OpenSSL::SSL::SSL::Context#add_certificate]. + attr_accessor :extra_chain_cert + + # Sets or returns the OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object. + attr_accessor :key + + # Sets or returns the SSL timeout seconds. + attr_accessor :ssl_timeout + + # Sets or returns the SSL version. + # See {OpenSSL::SSL::SSLContext#ssl_version=}[OpenSSL::SSL::SSL::Context#ssl_version=]. + attr_accessor :ssl_version + + # Sets or returns the minimum SSL version. + # See {OpenSSL::SSL::SSLContext#min_version=}[OpenSSL::SSL::SSL::Context#min_version=]. + attr_accessor :min_version + + # Sets or returns the maximum SSL version. + # See {OpenSSL::SSL::SSLContext#max_version=}[OpenSSL::SSL::SSL::Context#max_version=]. + attr_accessor :max_version + + # Sets or returns the callback for the server certification verification. + attr_accessor :verify_callback + + # Sets or returns the maximum depth for the certificate chain verification. + attr_accessor :verify_depth + + # Sets or returns the flags for server the certification verification + # at the beginning of the SSL/TLS session. + # OpenSSL::SSL::VERIFY_NONE or OpenSSL::SSL::VERIFY_PEER are acceptable. + attr_accessor :verify_mode + + # Sets or returns whether to verify that the server certificate is valid + # for the hostname. + # See {OpenSSL::SSL::SSLContext#verify_hostname=}[OpenSSL::SSL::SSL::Context#verify_hostname=]. + attr_accessor :verify_hostname + + # Returns the X509 certificate chain (an array of strings) + # for the session's socket peer, + # or +nil+ if none. + def peer_cert + if not use_ssl? or not @socket + return nil + end + @socket.io.peer_cert + end + + # Starts an \HTTP session. + # + # Without a block, returns +self+: + # + # http = Gem::Net::HTTP.new(hostname) + # # => #<Gem::Net::HTTP jsonplaceholder.typicode.com:80 open=false> + # http.start + # # => #<Gem::Net::HTTP jsonplaceholder.typicode.com:80 open=true> + # http.started? # => true + # http.finish + # + # With a block, calls the block with +self+, + # finishes the session when the block exits, + # and returns the block's value: + # + # http.start do |http| + # http + # end + # # => #<Gem::Net::HTTP jsonplaceholder.typicode.com:80 open=false> + # http.started? # => false + # + def start # :yield: http + raise IOError, 'HTTP session already opened' if @started + if block_given? + begin + do_start + return yield(self) + ensure + do_finish + end + end + do_start + self + end + + # Finishes the \HTTP session: + # + # http = Gem::Net::HTTP.new(hostname) + # http.start + # http.started? # => true + # http.finish # => nil + # http.started? # => false + # + # Raises IOError if not in a session. + def finish + raise IOError, 'HTTP session not yet started' unless started? + do_finish + end + + # :stopdoc: + def do_start + connect + @started = true + end + private :do_start + + def connect + if use_ssl? + # reference early to load OpenSSL before connecting, + # as OpenSSL may take time to load. + @ssl_context = OpenSSL::SSL::SSLContext.new + end + + if proxy? then + conn_addr = proxy_address + conn_port = proxy_port + else + conn_addr = conn_address + conn_port = port + end + + debug "opening connection to #{conn_addr}:#{conn_port}..." + begin + s = timeouted_connect(conn_addr, conn_port) + rescue => e + if (defined?(IO::TimeoutError) && e.is_a?(IO::TimeoutError)) || e.is_a?(Errno::ETIMEDOUT) # for compatibility with previous versions + e = Gem::Net::OpenTimeout.new(e) + end + raise e, "Failed to open TCP connection to " + + "#{conn_addr}:#{conn_port} (#{e.message})" + end + s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + debug "opened" + if use_ssl? + if proxy? + if @proxy_use_ssl + proxy_sock = OpenSSL::SSL::SSLSocket.new(s) + ssl_socket_connect(proxy_sock, @open_timeout) + else + proxy_sock = s + end + proxy_sock = BufferedIO.new(proxy_sock, read_timeout: @read_timeout, + write_timeout: @write_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) + buf = +"CONNECT #{conn_address}:#{@port} HTTP/#{HTTPVersion}\r\n" \ + "Host: #{@address}:#{@port}\r\n" + if proxy_user + credential = ["#{proxy_user}:#{proxy_pass}"].pack('m0') + buf << "Proxy-Authorization: Basic #{credential}\r\n" + end + buf << "\r\n" + proxy_sock.write(buf) + HTTPResponse.read_new(proxy_sock).value + # assuming nothing left in buffers after successful CONNECT response + end + + ssl_parameters = Hash.new + iv_list = instance_variables + SSL_IVNAMES.each_with_index do |ivname, i| + if iv_list.include?(ivname) + value = instance_variable_get(ivname) + unless value.nil? + ssl_parameters[SSL_ATTRIBUTES[i]] = value + end + end + end + @ssl_context.set_params(ssl_parameters) + unless @ssl_context.session_cache_mode.nil? # a dummy method on JRuby + @ssl_context.session_cache_mode = + OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | + OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE + end + if @ssl_context.respond_to?(:session_new_cb) # not implemented under JRuby + @ssl_context.session_new_cb = proc {|sock, sess| @ssl_session = sess } + end + + # Still do the post_connection_check below even if connecting + # to IP address + verify_hostname = @ssl_context.verify_hostname + + # Server Name Indication (SNI) RFC 3546/6066 + case @address + when Gem::Resolv::IPv4::Regex, Gem::Resolv::IPv6::Regex + # don't set SNI, as IP addresses in SNI is not valid + # per RFC 6066, section 3. + + # Avoid openssl warning + @ssl_context.verify_hostname = false + else + ssl_host_address = @address + end + + debug "starting SSL for #{conn_addr}:#{conn_port}..." + s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context) + s.sync_close = true + s.hostname = ssl_host_address if s.respond_to?(:hostname=) && ssl_host_address + + if @ssl_session and + Process.clock_gettime(Process::CLOCK_REALTIME) < @ssl_session.time.to_f + @ssl_session.timeout + s.session = @ssl_session + end + ssl_socket_connect(s, @open_timeout) + if (@ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE) && verify_hostname + s.post_connection_check(@address) + end + debug "SSL established, protocol: #{s.ssl_version}, cipher: #{s.cipher[0]}" + end + @socket = BufferedIO.new(s, read_timeout: @read_timeout, + write_timeout: @write_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) + @last_communicated = nil + on_connect + rescue => exception + if s + debug "Conn close because of connect error #{exception}" + s.close + end + raise + end + private :connect + + tcp_socket_parameters = TCPSocket.instance_method(:initialize).parameters + TCP_SOCKET_NEW_HAS_OPEN_TIMEOUT = if tcp_socket_parameters != [[:rest]] + tcp_socket_parameters.include?([:key, :open_timeout]) + else + # Use Socket.tcp to find out since there is no parameters information for TCPSocket#initialize + # See discussion in https://github.com/ruby/net-http/pull/224 + Socket.method(:tcp).parameters.include?([:key, :open_timeout]) + end + private_constant :TCP_SOCKET_NEW_HAS_OPEN_TIMEOUT + + def timeouted_connect(conn_addr, conn_port) + if TCP_SOCKET_NEW_HAS_OPEN_TIMEOUT + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port, open_timeout: @open_timeout) + else + Gem::Timeout.timeout(@open_timeout, Gem::Net::OpenTimeout) { + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) + } + end + end + private :timeouted_connect + + def on_connect + end + private :on_connect + + def do_finish + @started = false + @socket.close if @socket + @socket = nil + end + private :do_finish + + # + # proxy + # + + public + + # no proxy + @is_proxy_class = false + @proxy_from_env = false + @proxy_addr = nil + @proxy_port = nil + @proxy_user = nil + @proxy_pass = nil + @proxy_use_ssl = nil + + # Creates an \HTTP proxy class which behaves like \Gem::Net::HTTP, but + # performs all access via the specified proxy. + # + # This class is obsolete. You may pass these same parameters directly to + # \Gem::Net::HTTP.new. See Gem::Net::HTTP.new for details of the arguments. + def HTTP.Proxy(p_addr = :ENV, p_port = nil, p_user = nil, p_pass = nil, p_use_ssl = nil) #:nodoc: + return self unless p_addr + + Class.new(self) { + @is_proxy_class = true + + if p_addr == :ENV then + @proxy_from_env = true + @proxy_address = nil + @proxy_port = nil + else + @proxy_from_env = false + @proxy_address = p_addr + @proxy_port = p_port || default_port + end + + @proxy_user = p_user + @proxy_pass = p_pass + @proxy_use_ssl = p_use_ssl + } + end + + # :startdoc: + + class << HTTP + # Returns true if self is a class which was created by HTTP::Proxy. + def proxy_class? + defined?(@is_proxy_class) ? @is_proxy_class : false + end + + # Returns the address of the proxy host, or +nil+ if none; + # see Gem::Net::HTTP@Proxy+Server. + attr_reader :proxy_address + + # Returns the port number of the proxy host, or +nil+ if none; + # see Gem::Net::HTTP@Proxy+Server. + attr_reader :proxy_port + + # Returns the user name for accessing the proxy, or +nil+ if none; + # see Gem::Net::HTTP@Proxy+Server. + attr_reader :proxy_user + + # Returns the password for accessing the proxy, or +nil+ if none; + # see Gem::Net::HTTP@Proxy+Server. + attr_reader :proxy_pass + + # Use SSL when talking to the proxy. If Gem::Net::HTTP does not use a proxy, nil. + attr_reader :proxy_use_ssl + end + + # Returns +true+ if a proxy server is defined, +false+ otherwise; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + def proxy? + !!(@proxy_from_env ? proxy_uri : @proxy_address) + end + + # Returns +true+ if the proxy server is defined in the environment, + # +false+ otherwise; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + def proxy_from_env? + @proxy_from_env + end + + # The proxy Gem::URI determined from the environment for this connection. + def proxy_uri # :nodoc: + return if @proxy_uri == false + @proxy_uri ||= Gem::URI::HTTP.new( + "http", nil, address, port, nil, nil, nil, nil, nil + ).find_proxy || false + @proxy_uri || nil + end + + # Returns the address of the proxy server, if defined, +nil+ otherwise; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + def proxy_address + if @proxy_from_env then + proxy_uri&.hostname + else + @proxy_address + end + end + + # Returns the port number of the proxy server, if defined, +nil+ otherwise; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + def proxy_port + if @proxy_from_env then + proxy_uri&.port + else + @proxy_port + end + end + + # Returns the user name of the proxy server, if defined, +nil+ otherwise; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + def proxy_user + if @proxy_from_env + user = proxy_uri&.user + unescape(user) if user + else + @proxy_user + end + end + + # Returns the password of the proxy server, if defined, +nil+ otherwise; + # see {Proxy Server}[rdoc-ref:Gem::Net::HTTP@Proxy+Server]. + def proxy_pass + if @proxy_from_env + pass = proxy_uri&.password + unescape(pass) if pass + else + @proxy_pass + end + end + + alias proxyaddr proxy_address #:nodoc: obsolete + alias proxyport proxy_port #:nodoc: obsolete + + private + # :stopdoc: + + def unescape(value) + require 'cgi/escape' + require 'cgi/util' unless defined?(CGI::EscapeExt) + CGI.unescape(value) + end + + # without proxy, obsolete + + def conn_address # :nodoc: + @ipaddr || address() + end + + def conn_port # :nodoc: + port() + end + + def edit_path(path) + if proxy? + if path.start_with?("ftp://") || use_ssl? + path + else + "http://#{addr_port}#{path}" + end + else + path + end + end + # :startdoc: + + # + # HTTP operations + # + + public + + # :call-seq: + # get(path, initheader = nil) {|res| ... } + # + # Sends a GET request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Get object + # created from string +path+ and initial headers hash +initheader+. + # + # With a block given, calls the block with the response body: + # + # http = Gem::Net::HTTP.new(hostname) + # http.get('/todos/1') do |res| + # p res + # end # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # Output: + # + # "{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"delectus aut autem\",\n \"completed\": false\n}" + # + # With no block given, simply returns the response object: + # + # http.get('/') # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # Related: + # + # - Gem::Net::HTTP::Get: request class for \HTTP method GET. + # - Gem::Net::HTTP.get: sends GET request, returns response body. + # + def get(path, initheader = nil, dest = nil, &block) # :yield: +body_segment+ + res = nil + + request(Get.new(path, initheader)) {|r| + r.read_body dest, &block + res = r + } + res + end + + # Sends a HEAD request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Head object + # created from string +path+ and initial headers hash +initheader+: + # + # res = http.head('/todos/1') # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # res.body # => nil + # res.to_hash.take(3) + # # => + # [["date", ["Wed, 15 Feb 2023 15:25:42 GMT"]], + # ["content-type", ["application/json; charset=utf-8"]], + # ["connection", ["close"]]] + # + def head(path, initheader = nil) + request(Head.new(path, initheader)) + end + + # :call-seq: + # post(path, data, initheader = nil) {|res| ... } + # + # Sends a POST request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Post object + # created from string +path+, string +data+, and initial headers hash +initheader+. + # + # With a block given, calls the block with the response body: + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.post('/todos', data) do |res| + # p res + # end # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + # Output: + # + # "{\n \"{\\\"userId\\\": 1, \\\"id\\\": 1, \\\"title\\\": \\\"delectus aut autem\\\", \\\"completed\\\": false}\": \"\",\n \"id\": 201\n}" + # + # With no block given, simply returns the response object: + # + # http.post('/todos', data) # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + # Related: + # + # - Gem::Net::HTTP::Post: request class for \HTTP method POST. + # - Gem::Net::HTTP.post: sends POST request, returns response body. + # + def post(path, data, initheader = nil, dest = nil, &block) # :yield: +body_segment+ + send_entity(path, data, initheader, dest, Post, &block) + end + + # :call-seq: + # patch(path, data, initheader = nil) {|res| ... } + # + # Sends a PATCH request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Patch object + # created from string +path+, string +data+, and initial headers hash +initheader+. + # + # With a block given, calls the block with the response body: + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.patch('/todos/1', data) do |res| + # p res + # end # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # Output: + # + # "{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"delectus aut autem\",\n \"completed\": false,\n \"{\\\"userId\\\": 1, \\\"id\\\": 1, \\\"title\\\": \\\"delectus aut autem\\\", \\\"completed\\\": false}\": \"\"\n}" + # + # With no block given, simply returns the response object: + # + # http.patch('/todos/1', data) # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + def patch(path, data, initheader = nil, dest = nil, &block) # :yield: +body_segment+ + send_entity(path, data, initheader, dest, Patch, &block) + end + + # Sends a PUT request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Put object + # created from string +path+, string +data+, and initial headers hash +initheader+. + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.put('/todos/1', data) # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # Related: + # + # - Gem::Net::HTTP::Put: request class for \HTTP method PUT. + # - Gem::Net::HTTP.put: sends PUT request, returns response body. + # + def put(path, data, initheader = nil) + request(Put.new(path, initheader), data) + end + + # Sends a PROPPATCH request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Proppatch object + # created from string +path+, string +body+, and initial headers hash +initheader+. + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.proppatch('/todos/1', data) + # + def proppatch(path, body, initheader = nil) + request(Proppatch.new(path, initheader), body) + end + + # Sends a LOCK request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Lock object + # created from string +path+, string +body+, and initial headers hash +initheader+. + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.lock('/todos/1', data) + # + def lock(path, body, initheader = nil) + request(Lock.new(path, initheader), body) + end + + # Sends an UNLOCK request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Unlock object + # created from string +path+, string +body+, and initial headers hash +initheader+. + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.unlock('/todos/1', data) + # + def unlock(path, body, initheader = nil) + request(Unlock.new(path, initheader), body) + end + + # Sends an Options request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Options object + # created from string +path+ and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.options('/') + # + def options(path, initheader = nil) + request(Options.new(path, initheader)) + end + + # Sends a PROPFIND request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Propfind object + # created from string +path+, string +body+, and initial headers hash +initheader+. + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http = Gem::Net::HTTP.new(hostname) + # http.propfind('/todos/1', data) + # + def propfind(path, body = nil, initheader = {'Depth' => '0'}) + request(Propfind.new(path, initheader), body) + end + + # Sends a DELETE request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Delete object + # created from string +path+ and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.delete('/todos/1') + # + def delete(path, initheader = {'Depth' => 'Infinity'}) + request(Delete.new(path, initheader)) + end + + # Sends a MOVE request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Move object + # created from string +path+ and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.move('/todos/1') + # + def move(path, initheader = nil) + request(Move.new(path, initheader)) + end + + # Sends a COPY request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Copy object + # created from string +path+ and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.copy('/todos/1') + # + def copy(path, initheader = nil) + request(Copy.new(path, initheader)) + end + + # Sends a MKCOL request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Mkcol object + # created from string +path+, string +body+, and initial headers hash +initheader+. + # + # data = '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + # http.mkcol('/todos/1', data) + # http = Gem::Net::HTTP.new(hostname) + # + def mkcol(path, body = nil, initheader = nil) + request(Mkcol.new(path, initheader), body) + end + + # Sends a TRACE request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Trace object + # created from string +path+ and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.trace('/todos/1') + # + def trace(path, initheader = nil) + request(Trace.new(path, initheader)) + end + + # Sends a GET request to the server; + # forms the response into a Gem::Net::HTTPResponse object. + # + # The request is based on the Gem::Net::HTTP::Get object + # created from string +path+ and initial headers hash +initheader+. + # + # With no block given, returns the response object: + # + # http = Gem::Net::HTTP.new(hostname) + # http.request_get('/todos') # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # With a block given, calls the block with the response object + # and returns the response object: + # + # http.request_get('/todos') do |res| + # p res + # end # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # Output: + # + # #<Gem::Net::HTTPOK 200 OK readbody=false> + # + def request_get(path, initheader = nil, &block) # :yield: +response+ + request(Get.new(path, initheader), &block) + end + + # Sends a HEAD request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Head object + # created from string +path+ and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.head('/todos/1') # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + def request_head(path, initheader = nil, &block) + request(Head.new(path, initheader), &block) + end + + # Sends a POST request to the server; + # forms the response into a Gem::Net::HTTPResponse object. + # + # The request is based on the Gem::Net::HTTP::Post object + # created from string +path+, string +data+, and initial headers hash +initheader+. + # + # With no block given, returns the response object: + # + # http = Gem::Net::HTTP.new(hostname) + # http.post('/todos', 'xyzzy') + # # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + # With a block given, calls the block with the response body + # and returns the response object: + # + # http.post('/todos', 'xyzzy') do |res| + # p res + # end # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + # Output: + # + # "{\n \"xyzzy\": \"\",\n \"id\": 201\n}" + # + def request_post(path, data, initheader = nil, &block) # :yield: +response+ + request Post.new(path, initheader), data, &block + end + + # Sends a PUT request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTP::Put object + # created from string +path+, string +data+, and initial headers hash +initheader+. + # + # http = Gem::Net::HTTP.new(hostname) + # http.put('/todos/1', 'xyzzy') + # # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + def request_put(path, data, initheader = nil, &block) #:nodoc: + request Put.new(path, initheader), data, &block + end + + alias get2 request_get #:nodoc: obsolete + alias head2 request_head #:nodoc: obsolete + alias post2 request_post #:nodoc: obsolete + alias put2 request_put #:nodoc: obsolete + + # Sends an \HTTP request to the server; + # returns an instance of a subclass of Gem::Net::HTTPResponse. + # + # The request is based on the Gem::Net::HTTPRequest object + # created from string +path+, string +data+, and initial headers hash +header+. + # That object is an instance of the + # {subclass of Gem::Net::HTTPRequest}[rdoc-ref:Gem::Net::HTTPRequest@Request+Subclasses], + # that corresponds to the given uppercase string +name+, + # which must be + # an {HTTP request method}[https://en.wikipedia.org/wiki/HTTP#Request_methods] + # or a {WebDAV request method}[https://en.wikipedia.org/wiki/WebDAV#Implementation]. + # + # Examples: + # + # http = Gem::Net::HTTP.new(hostname) + # http.send_request('GET', '/todos/1') + # # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # http.send_request('POST', '/todos', 'xyzzy') + # # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + def send_request(name, path, data = nil, header = nil) + has_response_body = name != 'HEAD' + r = HTTPGenericRequest.new(name,(data ? true : false),has_response_body,path,header) + request r, data + end + + # Sends the given request +req+ to the server; + # forms the response into a Gem::Net::HTTPResponse object. + # + # The given +req+ must be an instance of a + # {subclass of Gem::Net::HTTPRequest}[rdoc-ref:Gem::Net::HTTPRequest@Request+Subclasses]. + # Argument +body+ should be given only if needed for the request. + # + # With no block given, returns the response object: + # + # http = Gem::Net::HTTP.new(hostname) + # + # req = Gem::Net::HTTP::Get.new('/todos/1') + # http.request(req) + # # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # req = Gem::Net::HTTP::Post.new('/todos') + # http.request(req, 'xyzzy') + # # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + # With a block given, calls the block with the response and returns the response: + # + # req = Gem::Net::HTTP::Get.new('/todos/1') + # http.request(req) do |res| + # p res + # end # => #<Gem::Net::HTTPOK 200 OK readbody=true> + # + # Output: + # + # #<Gem::Net::HTTPOK 200 OK readbody=false> + # + def request(req, body = nil, &block) # :yield: +response+ + unless started? + start { + req['connection'] ||= 'close' + return request(req, body, &block) + } + end + if proxy_user() + req.proxy_basic_auth proxy_user(), proxy_pass() unless use_ssl? + end + req.set_body_internal body + res = transport_request(req, &block) + if sspi_auth?(res) + sspi_auth(req) + res = transport_request(req, &block) + end + res + end + + private + + # Executes a request which uses a representation + # and returns its body. + def send_entity(path, data, initheader, dest, type, &block) + res = nil + request(type.new(path, initheader), data) {|r| + r.read_body dest, &block + res = r + } + res + end + + # :stopdoc: + + IDEMPOTENT_METHODS_ = %w/GET HEAD PUT DELETE OPTIONS TRACE/.freeze # :nodoc: + + def transport_request(req) + count = 0 + begin + begin_transport req + res = catch(:response) { + begin + req.exec @socket, @curr_http_version, edit_path(req.path) + rescue Errno::EPIPE + # Failure when writing full request, but we can probably + # still read the received response. + end + + begin + res = HTTPResponse.read_new(@socket) + res.decode_content = req.decode_content + res.body_encoding = @response_body_encoding + res.ignore_eof = @ignore_eof + end while res.kind_of?(HTTPInformation) + + res.uri = req.uri + + res + } + res.reading_body(@socket, req.response_body_permitted?) { + if block_given? + count = max_retries # Don't restart in the middle of a download + yield res + end + } + rescue Gem::Net::OpenTimeout + raise + rescue Gem::Net::ReadTimeout, IOError, EOFError, + Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, Errno::ETIMEDOUT, + # avoid a dependency on OpenSSL + defined?(OpenSSL::SSL) ? OpenSSL::SSL::SSLError : IOError, + Gem::Timeout::Error => exception + if count < max_retries && IDEMPOTENT_METHODS_.include?(req.method) + count += 1 + @socket.close if @socket + debug "Conn close because of error #{exception}, and retry" + retry + end + debug "Conn close because of error #{exception}" + @socket.close if @socket + raise + end + + end_transport req, res + res + rescue => exception + debug "Conn close because of error #{exception}" + @socket.close if @socket + raise exception + end + + def begin_transport(req) + if @socket.closed? + connect + elsif @last_communicated + if @last_communicated + @keep_alive_timeout < Process.clock_gettime(Process::CLOCK_MONOTONIC) + debug 'Conn close because of keep_alive_timeout' + @socket.close + connect + elsif @socket.io.to_io.wait_readable(0) && @socket.eof? + debug "Conn close because of EOF" + @socket.close + connect + end + end + + if not req.response_body_permitted? and @close_on_empty_response + req['connection'] ||= 'close' + end + + req.update_uri address, port, use_ssl? + req['host'] ||= addr_port() + end + + def end_transport(req, res) + @curr_http_version = res.http_version + @last_communicated = nil + if @socket.closed? + debug 'Conn socket closed' + elsif not res.body and @close_on_empty_response + debug 'Conn close' + @socket.close + elsif keep_alive?(req, res) + debug 'Conn keep-alive' + @last_communicated = Process.clock_gettime(Process::CLOCK_MONOTONIC) + else + debug 'Conn close' + @socket.close + end + end + + def keep_alive?(req, res) + return false if req.connection_close? + if @curr_http_version <= '1.0' + res.connection_keep_alive? + else # HTTP/1.1 or later + not res.connection_close? + end + end + + def sspi_auth?(res) + return false unless @sspi_enabled + if res.kind_of?(HTTPProxyAuthenticationRequired) and + proxy? and res["Proxy-Authenticate"].include?("Negotiate") + begin + require 'win32/sspi' + true + rescue LoadError + false + end + else + false + end + end + + def sspi_auth(req) + n = Win32::SSPI::NegotiateAuth.new + req["Proxy-Authorization"] = "Negotiate #{n.get_initial_token}" + # Some versions of ISA will close the connection if this isn't present. + req["Connection"] = "Keep-Alive" + req["Proxy-Connection"] = "Keep-Alive" + res = transport_request(req) + authphrase = res["Proxy-Authenticate"] or return res + req["Proxy-Authorization"] = "Negotiate #{n.complete_authentication(authphrase)}" + rescue => err + raise HTTPAuthenticationError.new('HTTP authentication failed', err) + end + + # + # utils + # + + private + + def addr_port + addr = address + addr = "[#{addr}]" if addr.include?(":") + default_port = use_ssl? ? HTTP.https_default_port : HTTP.http_default_port + default_port == port ? addr : "#{addr}:#{port}" + end + + # Adds a message to debugging output + def debug(msg) + return unless @debug_output + @debug_output << msg + @debug_output << "\n" + end + + alias_method :D, :debug + end + + # for backward compatibility until Ruby 4.0 + # https://bugs.ruby-lang.org/issues/20900 + # https://github.com/bblimke/webmock/pull/1081 + HTTPSession = HTTP + deprecate_constant :HTTPSession +end + +require_relative 'http/exceptions' + +require_relative 'http/header' + +require_relative 'http/generic_request' +require_relative 'http/request' +require_relative 'http/requests' + +require_relative 'http/response' +require_relative 'http/responses' + +require_relative 'http/proxy_delta' diff --git a/lib/rubygems/vendor/net-http/lib/net/http/exceptions.rb b/lib/rubygems/vendor/net-http/lib/net/http/exceptions.rb new file mode 100644 index 0000000000..218df9a8bd --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/exceptions.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +module Gem::Net + # Gem::Net::HTTP exception class. + # You cannot use Gem::Net::HTTPExceptions directly; instead, you must use + # its subclasses. + module HTTPExceptions # :nodoc: + def initialize(msg, res) #:nodoc: + super msg + @response = res + end + attr_reader :response + alias data response #:nodoc: obsolete + end + + # :stopdoc: + class HTTPError < ProtocolError + include HTTPExceptions + end + + class HTTPRetriableError < ProtoRetriableError + include HTTPExceptions + end + + class HTTPClientException < ProtoServerError + include HTTPExceptions + end + + class HTTPFatalError < ProtoFatalError + include HTTPExceptions + end + + # We cannot use the name "HTTPServerError", it is the name of the response. + HTTPServerException = HTTPClientException # :nodoc: + deprecate_constant(:HTTPServerException) +end diff --git a/lib/rubygems/vendor/net-http/lib/net/http/generic_request.rb b/lib/rubygems/vendor/net-http/lib/net/http/generic_request.rb new file mode 100644 index 0000000000..d6496d4ac1 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/generic_request.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true +# +# \HTTPGenericRequest is the parent of the Gem::Net::HTTPRequest class. +# +# Do not use this directly; instead, use a subclass of Gem::Net::HTTPRequest. +# +# == About the Examples +# +# :include: doc/net-http/examples.rdoc +# +class Gem::Net::HTTPGenericRequest + + include Gem::Net::HTTPHeader + + def initialize(m, reqbody, resbody, uri_or_path, initheader = nil) # :nodoc: + @method = m + @request_has_body = reqbody + @response_has_body = resbody + + if Gem::URI === uri_or_path then + raise ArgumentError, "not an HTTP Gem::URI" unless Gem::URI::HTTP === uri_or_path + hostname = uri_or_path.host + raise ArgumentError, "no host component for Gem::URI" unless (hostname && hostname.length > 0) + @uri = uri_or_path.dup + @path = uri_or_path.request_uri + raise ArgumentError, "no HTTP request path given" unless @path + else + @uri = nil + raise ArgumentError, "no HTTP request path given" unless uri_or_path + raise ArgumentError, "HTTP request path is empty" if uri_or_path.empty? + @path = uri_or_path.dup + end + + @decode_content = false + + if Gem::Net::HTTP::HAVE_ZLIB then + if !initheader || + !initheader.keys.any? { |k| + %w[accept-encoding range].include? k.downcase + } then + @decode_content = true if @response_has_body + initheader = initheader ? initheader.dup : {} + initheader["accept-encoding"] = + "gzip;q=1.0,deflate;q=0.6,identity;q=0.3" + end + end + + initialize_http_header initheader + self['Accept'] ||= '*/*' + self['User-Agent'] ||= 'Ruby' + self['Host'] ||= @uri.authority if @uri + @body = nil + @body_stream = nil + @body_data = nil + end + + # Returns the string method name for the request: + # + # Gem::Net::HTTP::Get.new(uri).method # => "GET" + # Gem::Net::HTTP::Post.new(uri).method # => "POST" + # + attr_reader :method + + # Returns the string path for the request: + # + # Gem::Net::HTTP::Get.new(uri).path # => "/" + # Gem::Net::HTTP::Post.new('example.com').path # => "example.com" + # + attr_reader :path + + # Returns the Gem::URI object for the request, or +nil+ if none: + # + # Gem::Net::HTTP::Get.new(uri).uri + # # => #<Gem::URI::HTTPS https://jsonplaceholder.typicode.com/> + # Gem::Net::HTTP::Get.new('example.com').uri # => nil + # + attr_reader :uri + + # Returns +false+ if the request's header <tt>'Accept-Encoding'</tt> + # has been set manually or deleted + # (indicating that the user intends to handle encoding in the response), + # +true+ otherwise: + # + # req = Gem::Net::HTTP::Get.new(uri) # => #<Gem::Net::HTTP::Get GET> + # req['Accept-Encoding'] # => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3" + # req.decode_content # => true + # req['Accept-Encoding'] = 'foo' + # req.decode_content # => false + # req.delete('Accept-Encoding') + # req.decode_content # => false + # + attr_reader :decode_content + + # Returns a string representation of the request: + # + # Gem::Net::HTTP::Post.new(uri).inspect # => "#<Gem::Net::HTTP::Post POST>" + # + def inspect + "\#<#{self.class} #{@method}>" + end + + # Returns a string representation of the request with the details for pp: + # + # require 'pp' + # post = Gem::Net::HTTP::Post.new(uri) + # post.inspect # => "#<Gem::Net::HTTP::Post POST>" + # post.pretty_inspect + # # => #<Gem::Net::HTTP::Post + # POST + # path="/" + # headers={"accept-encoding" => ["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"], + # "accept" => ["*/*"], + # "user-agent" => ["Ruby"], + # "host" => ["www.ruby-lang.org"]}> + # + def pretty_print(q) + q.object_group(self) { + q.breakable + q.text @method + q.breakable + q.text "path="; q.pp @path + q.breakable + q.text "headers="; q.pp to_hash + } + end + + ## + # Don't automatically decode response content-encoding if the user indicates + # they want to handle it. + + def []=(key, val) # :nodoc: + @decode_content = false if key.downcase == 'accept-encoding' + + super key, val + end + + # Returns whether the request may have a body: + # + # Gem::Net::HTTP::Post.new(uri).request_body_permitted? # => true + # Gem::Net::HTTP::Get.new(uri).request_body_permitted? # => false + # + def request_body_permitted? + @request_has_body + end + + # Returns whether the response may have a body: + # + # Gem::Net::HTTP::Post.new(uri).response_body_permitted? # => true + # Gem::Net::HTTP::Head.new(uri).response_body_permitted? # => false + # + def response_body_permitted? + @response_has_body + end + + def body_exist? # :nodoc: + warn "Gem::Net::HTTPRequest#body_exist? is obsolete; use response_body_permitted?", uplevel: 1 if $VERBOSE + response_body_permitted? + end + + # Returns the string body for the request, or +nil+ if there is none: + # + # req = Gem::Net::HTTP::Post.new(uri) + # req.body # => nil + # req.body = '{"title": "foo","body": "bar","userId": 1}' + # req.body # => "{\"title\": \"foo\",\"body\": \"bar\",\"userId\": 1}" + # + attr_reader :body + + # Sets the body for the request: + # + # req = Gem::Net::HTTP::Post.new(uri) + # req.body # => nil + # req.body = '{"title": "foo","body": "bar","userId": 1}' + # req.body # => "{\"title\": \"foo\",\"body\": \"bar\",\"userId\": 1}" + # + def body=(str) + @body = str + @body_stream = nil + @body_data = nil + str + end + + # Returns the body stream object for the request, or +nil+ if there is none: + # + # req = Gem::Net::HTTP::Post.new(uri) # => #<Gem::Net::HTTP::Post POST> + # req.body_stream # => nil + # require 'stringio' + # req.body_stream = StringIO.new('xyzzy') # => #<StringIO:0x0000027d1e5affa8> + # req.body_stream # => #<StringIO:0x0000027d1e5affa8> + # + attr_reader :body_stream + + # Sets the body stream for the request: + # + # req = Gem::Net::HTTP::Post.new(uri) # => #<Gem::Net::HTTP::Post POST> + # req.body_stream # => nil + # require 'stringio' + # req.body_stream = StringIO.new('xyzzy') # => #<StringIO:0x0000027d1e5affa8> + # req.body_stream # => #<StringIO:0x0000027d1e5affa8> + # + def body_stream=(input) + @body = nil + @body_stream = input + @body_data = nil + input + end + + def set_body_internal(str) #:nodoc: internal use only + raise ArgumentError, "both of body argument and HTTPRequest#body set" if str and (@body or @body_stream) + self.body = str if str + if @body.nil? && @body_stream.nil? && @body_data.nil? && request_body_permitted? + self.body = '' + end + end + + # + # write + # + + def exec(sock, ver, path) #:nodoc: internal use only + if @body + send_request_with_body sock, ver, path, @body + elsif @body_stream + send_request_with_body_stream sock, ver, path, @body_stream + elsif @body_data + send_request_with_body_data sock, ver, path, @body_data + else + write_header sock, ver, path + end + end + + def update_uri(addr, port, ssl) # :nodoc: internal use only + # reflect the connection and @path to @uri + return unless @uri + + if ssl + scheme = 'https' + klass = Gem::URI::HTTPS + else + scheme = 'http' + klass = Gem::URI::HTTP + end + + if host = self['host'] + host = Gem::URI.parse("//#{host}").host # Remove a port component from the existing Host header + elsif host = @uri.host + else + host = addr + end + # convert the class of the Gem::URI + if @uri.is_a?(klass) + @uri.host = host + @uri.port = port + else + @uri = klass.new( + scheme, @uri.userinfo, + host, port, nil, + @uri.path, nil, @uri.query, nil) + end + end + + private + + # :stopdoc: + + class Chunker #:nodoc: + def initialize(sock) + @sock = sock + @prev = nil + end + + def write(buf) + # avoid memcpy() of buf, buf can huge and eat memory bandwidth + rv = buf.bytesize + @sock.write("#{rv.to_s(16)}\r\n", buf, "\r\n") + rv + end + + def finish + @sock.write("0\r\n\r\n") + end + end + + def send_request_with_body(sock, ver, path, body) + self.content_length = body.bytesize + delete 'Transfer-Encoding' + write_header sock, ver, path + wait_for_continue sock, ver if sock.continue_timeout + sock.write body + end + + def send_request_with_body_stream(sock, ver, path, f) + unless content_length() or chunked? + raise ArgumentError, + "Content-Length not given and Transfer-Encoding is not `chunked'" + end + write_header sock, ver, path + wait_for_continue sock, ver if sock.continue_timeout + if chunked? + chunker = Chunker.new(sock) + IO.copy_stream(f, chunker) + chunker.finish + else + IO.copy_stream(f, sock) + end + end + + def send_request_with_body_data(sock, ver, path, params) + if /\Amultipart\/form-data\z/i !~ self.content_type + self.content_type = 'application/x-www-form-urlencoded' + return send_request_with_body(sock, ver, path, Gem::URI.encode_www_form(params)) + end + + opt = @form_option.dup + require 'securerandom' unless defined?(SecureRandom) + opt[:boundary] ||= SecureRandom.urlsafe_base64(40) + self.set_content_type(self.content_type, boundary: opt[:boundary]) + if chunked? + write_header sock, ver, path + encode_multipart_form_data(sock, params, opt) + else + require 'tempfile' + file = Tempfile.new('multipart') + file.binmode + encode_multipart_form_data(file, params, opt) + file.rewind + self.content_length = file.size + write_header sock, ver, path + IO.copy_stream(file, sock) + file.close(true) + end + end + + def encode_multipart_form_data(out, params, opt) + charset = opt[:charset] + boundary = opt[:boundary] + require 'securerandom' unless defined?(SecureRandom) + boundary ||= SecureRandom.urlsafe_base64(40) + chunked_p = chunked? + + buf = +'' + params.each do |key, value, h={}| + key = quote_string(key, charset) + filename = + h.key?(:filename) ? h[:filename] : + value.respond_to?(:to_path) ? File.basename(value.to_path) : + nil + + buf << "--#{boundary}\r\n" + if filename + filename = quote_string(filename, charset) + type = h[:content_type] || 'application/octet-stream' + buf << "Content-Disposition: form-data; " \ + "name=\"#{key}\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: #{type}\r\n\r\n" + if !out.respond_to?(:write) || !value.respond_to?(:read) + # if +out+ is not an IO or +value+ is not an IO + buf << (value.respond_to?(:read) ? value.read : value) + elsif value.respond_to?(:size) && chunked_p + # if +out+ is an IO and +value+ is a File, use IO.copy_stream + flush_buffer(out, buf, chunked_p) + out << "%x\r\n" % value.size if chunked_p + IO.copy_stream(value, out) + out << "\r\n" if chunked_p + else + # +out+ is an IO, and +value+ is not a File but an IO + flush_buffer(out, buf, chunked_p) + 1 while flush_buffer(out, value.read(4096), chunked_p) + end + else + # non-file field: + # HTML5 says, "The parts of the generated multipart/form-data + # resource that correspond to non-file fields must not have a + # Content-Type header specified." + buf << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n" + buf << (value.respond_to?(:read) ? value.read : value) + end + buf << "\r\n" + end + buf << "--#{boundary}--\r\n" + flush_buffer(out, buf, chunked_p) + out << "0\r\n\r\n" if chunked_p + end + + def quote_string(str, charset) + str = str.encode(charset, fallback:->(c){'&#%d;'%c.encode("UTF-8").ord}) if charset + str.gsub(/[\\"]/, '\\\\\&') + end + + def flush_buffer(out, buf, chunked_p) + return unless buf + out << "%x\r\n"%buf.bytesize if chunked_p + out << buf + out << "\r\n" if chunked_p + buf.clear + end + + ## + # Waits up to the continue timeout for a response from the server provided + # we're speaking HTTP 1.1 and are expecting a 100-continue response. + + def wait_for_continue(sock, ver) + if ver >= '1.1' and @header['expect'] and + @header['expect'].include?('100-continue') + if sock.io.to_io.wait_readable(sock.continue_timeout) + res = Gem::Net::HTTPResponse.read_new(sock) + unless res.kind_of?(Gem::Net::HTTPContinue) + res.decode_content = @decode_content + throw :response, res + end + end + end + end + + def write_header(sock, ver, path) + reqline = "#{@method} #{path} HTTP/#{ver}" + if /[\r\n]/ =~ reqline + raise ArgumentError, "A Request-Line must not contain CR or LF" + end + buf = +'' + buf << reqline << "\r\n" + each_capitalized do |k,v| + buf << "#{k}: #{v}\r\n" + end + buf << "\r\n" + sock.write buf + end + +end diff --git a/lib/rubygems/vendor/net-http/lib/net/http/header.rb b/lib/rubygems/vendor/net-http/lib/net/http/header.rb new file mode 100644 index 0000000000..bc68cd2eef --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/header.rb @@ -0,0 +1,985 @@ +# frozen_string_literal: true +# +# The \HTTPHeader module provides access to \HTTP headers. +# +# The module is included in: +# +# - Gem::Net::HTTPGenericRequest (and therefore Gem::Net::HTTPRequest). +# - Gem::Net::HTTPResponse. +# +# The headers are a hash-like collection of key/value pairs called _fields_. +# +# == Request and Response Fields +# +# Headers may be included in: +# +# - A Gem::Net::HTTPRequest object: +# the object's headers will be sent with the request. +# Any fields may be defined in the request; +# see {Setters}[rdoc-ref:Gem::Net::HTTPHeader@Setters]. +# - A Gem::Net::HTTPResponse object: +# the objects headers are usually those returned from the host. +# Fields may be retrieved from the object; +# see {Getters}[rdoc-ref:Gem::Net::HTTPHeader@Getters] +# and {Iterators}[rdoc-ref:Gem::Net::HTTPHeader@Iterators]. +# +# Exactly which fields should be sent or expected depends on the host; +# see: +# +# - {Request fields}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields]. +# - {Response fields}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Response_fields]. +# +# == About the Examples +# +# :include: doc/net-http/examples.rdoc +# +# == Fields +# +# A header field is a key/value pair. +# +# === Field Keys +# +# A field key may be: +# +# - A string: Key <tt>'Accept'</tt> is treated as if it were +# <tt>'Accept'.downcase</tt>; i.e., <tt>'accept'</tt>. +# - A symbol: Key <tt>:Accept</tt> is treated as if it were +# <tt>:Accept.to_s.downcase</tt>; i.e., <tt>'accept'</tt>. +# +# Examples: +# +# req = Gem::Net::HTTP::Get.new(uri) +# req[:accept] # => "*/*" +# req['Accept'] # => "*/*" +# req['ACCEPT'] # => "*/*" +# +# req['accept'] = 'text/html' +# req[:accept] = 'text/html' +# req['ACCEPT'] = 'text/html' +# +# === Field Values +# +# A field value may be returned as an array of strings or as a string: +# +# - These methods return field values as arrays: +# +# - #get_fields: Returns the array value for the given key, +# or +nil+ if it does not exist. +# - #to_hash: Returns a hash of all header fields: +# each key is a field name; its value is the array value for the field. +# +# - These methods return field values as string; +# the string value for a field is equivalent to +# <tt>self[key.downcase.to_s].join(', '))</tt>: +# +# - #[]: Returns the string value for the given key, +# or +nil+ if it does not exist. +# - #fetch: Like #[], but accepts a default value +# to be returned if the key does not exist. +# +# The field value may be set: +# +# - #[]=: Sets the value for the given key; +# the given value may be a string, a symbol, an array, or a hash. +# - #add_field: Adds a given value to a value for the given key +# (not overwriting the existing value). +# - #delete: Deletes the field for the given key. +# +# Example field values: +# +# - \String: +# +# req['Accept'] = 'text/html' # => "text/html" +# req['Accept'] # => "text/html" +# req.get_fields('Accept') # => ["text/html"] +# +# - \Symbol: +# +# req['Accept'] = :text # => :text +# req['Accept'] # => "text" +# req.get_fields('Accept') # => ["text"] +# +# - Simple array: +# +# req[:foo] = %w[bar baz bat] +# req[:foo] # => "bar, baz, bat" +# req.get_fields(:foo) # => ["bar", "baz", "bat"] +# +# - Simple hash: +# +# req[:foo] = {bar: 0, baz: 1, bat: 2} +# req[:foo] # => "bar, 0, baz, 1, bat, 2" +# req.get_fields(:foo) # => ["bar", "0", "baz", "1", "bat", "2"] +# +# - Nested: +# +# req[:foo] = [%w[bar baz], {bat: 0, bam: 1}] +# req[:foo] # => "bar, baz, bat, 0, bam, 1" +# req.get_fields(:foo) # => ["bar", "baz", "bat", "0", "bam", "1"] +# +# req[:foo] = {bar: %w[baz bat], bam: {bah: 0, bad: 1}} +# req[:foo] # => "bar, baz, bat, bam, bah, 0, bad, 1" +# req.get_fields(:foo) # => ["bar", "baz", "bat", "bam", "bah", "0", "bad", "1"] +# +# == Convenience Methods +# +# Various convenience methods retrieve values, set values, query values, +# set form values, or iterate over fields. +# +# === Setters +# +# \Method #[]= can set any field, but does little to validate the new value; +# some of the other setter methods provide some validation: +# +# - #[]=: Sets the string or array value for the given key. +# - #add_field: Creates or adds to the array value for the given key. +# - #basic_auth: Sets the string authorization header for <tt>'Authorization'</tt>. +# - #content_length=: Sets the integer length for field <tt>'Content-Length</tt>. +# - #content_type=: Sets the string value for field <tt>'Content-Type'</tt>. +# - #proxy_basic_auth: Sets the string authorization header for <tt>'Proxy-Authorization'</tt>. +# - #set_range: Sets the value for field <tt>'Range'</tt>. +# +# === Form Setters +# +# - #set_form: Sets an HTML form data set. +# - #set_form_data: Sets header fields and a body from HTML form data. +# +# === Getters +# +# \Method #[] can retrieve the value of any field that exists, +# but always as a string; +# some of the other getter methods return something different +# from the simple string value: +# +# - #[]: Returns the string field value for the given key. +# - #content_length: Returns the integer value of field <tt>'Content-Length'</tt>. +# - #content_range: Returns the Range value of field <tt>'Content-Range'</tt>. +# - #content_type: Returns the string value of field <tt>'Content-Type'</tt>. +# - #fetch: Returns the string field value for the given key. +# - #get_fields: Returns the array field value for the given +key+. +# - #main_type: Returns first part of the string value of field <tt>'Content-Type'</tt>. +# - #sub_type: Returns second part of the string value of field <tt>'Content-Type'</tt>. +# - #range: Returns an array of Range objects of field <tt>'Range'</tt>, or +nil+. +# - #range_length: Returns the integer length of the range given in field <tt>'Content-Range'</tt>. +# - #type_params: Returns the string parameters for <tt>'Content-Type'</tt>. +# +# === Queries +# +# - #chunked?: Returns whether field <tt>'Transfer-Encoding'</tt> is set to <tt>'chunked'</tt>. +# - #connection_close?: Returns whether field <tt>'Connection'</tt> is set to <tt>'close'</tt>. +# - #connection_keep_alive?: Returns whether field <tt>'Connection'</tt> is set to <tt>'keep-alive'</tt>. +# - #key?: Returns whether a given key exists. +# +# === Iterators +# +# - #each_capitalized: Passes each field capitalized-name/value pair to the block. +# - #each_capitalized_name: Passes each capitalized field name to the block. +# - #each_header: Passes each field name/value pair to the block. +# - #each_name: Passes each field name to the block. +# - #each_value: Passes each string field value to the block. +# +module Gem::Net::HTTPHeader + # The maximum length of HTTP header keys. + MAX_KEY_LENGTH = 1024 + # The maximum length of HTTP header values. + MAX_FIELD_LENGTH = 65536 + + def initialize_http_header(initheader) #:nodoc: + @header = {} + return unless initheader + initheader.each do |key, value| + warn "net/http: duplicated HTTP header: #{key}", uplevel: 3 if key?(key) and $VERBOSE + if value.nil? + warn "net/http: nil HTTP header: #{key}", uplevel: 3 if $VERBOSE + else + value = value.strip # raise error for invalid byte sequences + if key.to_s.bytesize > MAX_KEY_LENGTH + raise ArgumentError, "too long (#{key.bytesize} bytes) header: #{key[0, 30].inspect}..." + end + if value.to_s.bytesize > MAX_FIELD_LENGTH + raise ArgumentError, "header #{key} has too long field value: #{value.bytesize}" + end + if value.count("\r\n") > 0 + raise ArgumentError, "header #{key} has field value #{value.inspect}, this cannot include CR/LF" + end + @header[key.downcase.to_s] = [value] + end + end + end + + def size #:nodoc: obsolete + @header.size + end + + alias length size #:nodoc: obsolete + + # Returns the string field value for the case-insensitive field +key+, + # or +nil+ if there is no such key; + # see {Fields}[rdoc-ref:Gem::Net::HTTPHeader@Fields]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['Connection'] # => "keep-alive" + # res['Nosuch'] # => nil + # + # Note that some field values may be retrieved via convenience methods; + # see {Getters}[rdoc-ref:Gem::Net::HTTPHeader@Getters]. + def [](key) + a = @header[key.downcase.to_s] or return nil + a.join(', ') + end + + # Sets the value for the case-insensitive +key+ to +val+, + # overwriting the previous value if the field exists; + # see {Fields}[rdoc-ref:Gem::Net::HTTPHeader@Fields]: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req['Accept'] # => "*/*" + # req['Accept'] = 'text/html' + # req['Accept'] # => "text/html" + # + # Note that some field values may be set via convenience methods; + # see {Setters}[rdoc-ref:Gem::Net::HTTPHeader@Setters]. + def []=(key, val) + unless val + @header.delete key.downcase.to_s + return val + end + set_field(key, val) + end + + # Adds value +val+ to the value array for field +key+ if the field exists; + # creates the field with the given +key+ and +val+ if it does not exist. + # see {Fields}[rdoc-ref:Gem::Net::HTTPHeader@Fields]: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.add_field('Foo', 'bar') + # req['Foo'] # => "bar" + # req.add_field('Foo', 'baz') + # req['Foo'] # => "bar, baz" + # req.add_field('Foo', %w[baz bam]) + # req['Foo'] # => "bar, baz, baz, bam" + # req.get_fields('Foo') # => ["bar", "baz", "baz", "bam"] + # + def add_field(key, val) + stringified_downcased_key = key.downcase.to_s + if @header.key?(stringified_downcased_key) + append_field_value(@header[stringified_downcased_key], val) + else + set_field(key, val) + end + end + + # :stopdoc: + private def set_field(key, val) + case val + when Enumerable + ary = [] + append_field_value(ary, val) + @header[key.downcase.to_s] = ary + else + val = val.to_s # for compatibility use to_s instead of to_str + if val.b.count("\r\n") > 0 + raise ArgumentError, 'header field value cannot include CR/LF' + end + @header[key.downcase.to_s] = [val] + end + end + + private def append_field_value(ary, val) + case val + when Enumerable + val.each{|x| append_field_value(ary, x)} + else + val = val.to_s + if /[\r\n]/n.match?(val.b) + raise ArgumentError, 'header field value cannot include CR/LF' + end + ary.push val + end + end + # :startdoc: + + # Returns the array field value for the given +key+, + # or +nil+ if there is no such field; + # see {Fields}[rdoc-ref:Gem::Net::HTTPHeader@Fields]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res.get_fields('Connection') # => ["keep-alive"] + # res.get_fields('Nosuch') # => nil + # + def get_fields(key) + stringified_downcased_key = key.downcase.to_s + return nil unless @header[stringified_downcased_key] + @header[stringified_downcased_key].dup + end + + # call-seq: + # fetch(key, default_val = nil) {|key| ... } -> object + # fetch(key, default_val = nil) -> value or default_val + # + # With a block, returns the string value for +key+ if it exists; + # otherwise returns the value of the block; + # ignores the +default_val+; + # see {Fields}[rdoc-ref:Gem::Net::HTTPHeader@Fields]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # + # # Field exists; block not called. + # res.fetch('Connection') do |value| + # fail 'Cannot happen' + # end # => "keep-alive" + # + # # Field does not exist; block called. + # res.fetch('Nosuch') do |value| + # value.downcase + # end # => "nosuch" + # + # With no block, returns the string value for +key+ if it exists; + # otherwise, returns +default_val+ if it was given; + # otherwise raises an exception: + # + # res.fetch('Connection', 'Foo') # => "keep-alive" + # res.fetch('Nosuch', 'Foo') # => "Foo" + # res.fetch('Nosuch') # Raises KeyError. + # + def fetch(key, *args, &block) #:yield: +key+ + a = @header.fetch(key.downcase.to_s, *args, &block) + a.kind_of?(Array) ? a.join(', ') : a + end + + # Calls the block with each key/value pair: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res.each_header do |key, value| + # p [key, value] if key.start_with?('c') + # end + # + # Output: + # + # ["content-type", "application/json; charset=utf-8"] + # ["connection", "keep-alive"] + # ["cache-control", "max-age=43200"] + # ["cf-cache-status", "HIT"] + # ["cf-ray", "771d17e9bc542cf5-ORD"] + # + # Returns an enumerator if no block is given. + # + # Gem::Net::HTTPHeader#each is an alias for Gem::Net::HTTPHeader#each_header. + def each_header #:yield: +key+, +value+ + block_given? or return enum_for(__method__) { @header.size } + @header.each do |k,va| + yield k, va.join(', ') + end + end + + alias each each_header + + # Calls the block with each field key: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res.each_key do |key| + # p key if key.start_with?('c') + # end + # + # Output: + # + # "content-type" + # "connection" + # "cache-control" + # "cf-cache-status" + # "cf-ray" + # + # Returns an enumerator if no block is given. + # + # Gem::Net::HTTPHeader#each_name is an alias for Gem::Net::HTTPHeader#each_key. + def each_name(&block) #:yield: +key+ + block_given? or return enum_for(__method__) { @header.size } + @header.each_key(&block) + end + + alias each_key each_name + + # Calls the block with each capitalized field name: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res.each_capitalized_name do |key| + # p key if key.start_with?('C') + # end + # + # Output: + # + # "Content-Type" + # "Connection" + # "Cache-Control" + # "Cf-Cache-Status" + # "Cf-Ray" + # + # The capitalization is system-dependent; + # see {Case Mapping}[https://docs.ruby-lang.org/en/master/case_mapping_rdoc.html]. + # + # Returns an enumerator if no block is given. + def each_capitalized_name #:yield: +key+ + block_given? or return enum_for(__method__) { @header.size } + @header.each_key do |k| + yield capitalize(k) + end + end + + # Calls the block with each string field value: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res.each_value do |value| + # p value if value.start_with?('c') + # end + # + # Output: + # + # "chunked" + # "cf-q-config;dur=6.0000002122251e-06" + # "cloudflare" + # + # Returns an enumerator if no block is given. + def each_value #:yield: +value+ + block_given? or return enum_for(__method__) { @header.size } + @header.each_value do |va| + yield va.join(', ') + end + end + + # Removes the header for the given case-insensitive +key+ + # (see {Fields}[rdoc-ref:Gem::Net::HTTPHeader@Fields]); + # returns the deleted value, or +nil+ if no such field exists: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.delete('Accept') # => ["*/*"] + # req.delete('Nosuch') # => nil + # + def delete(key) + @header.delete(key.downcase.to_s) + end + + # Returns +true+ if the field for the case-insensitive +key+ exists, +false+ otherwise: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.key?('Accept') # => true + # req.key?('Nosuch') # => false + # + def key?(key) + @header.key?(key.downcase.to_s) + end + + # Returns a hash of the key/value pairs: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.to_hash + # # => + # {"accept-encoding"=>["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"], + # "accept"=>["*/*"], + # "user-agent"=>["Ruby"], + # "host"=>["jsonplaceholder.typicode.com"]} + # + def to_hash + @header.dup + end + + # Like #each_header, but the keys are returned in capitalized form. + # + # Gem::Net::HTTPHeader#canonical_each is an alias for Gem::Net::HTTPHeader#each_capitalized. + def each_capitalized + block_given? or return enum_for(__method__) { @header.size } + @header.each do |k,v| + yield capitalize(k), v.join(', ') + end + end + + alias canonical_each each_capitalized + + def capitalize(name) # :nodoc: + name.to_s.split('-'.freeze).map {|s| s.capitalize }.join('-'.freeze) + end + private :capitalize + + # Returns an array of Range objects that represent + # the value of field <tt>'Range'</tt>, + # or +nil+ if there is no such field; + # see {Range request header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#range-request-header]: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req['Range'] = 'bytes=0-99,200-299,400-499' + # req.range # => [0..99, 200..299, 400..499] + # req.delete('Range') + # req.range # # => nil + # + def range + return nil unless @header['range'] + + value = self['Range'] + # byte-range-set = *( "," OWS ) ( byte-range-spec / suffix-byte-range-spec ) + # *( OWS "," [ OWS ( byte-range-spec / suffix-byte-range-spec ) ] ) + # corrected collected ABNF + # http://tools.ietf.org/html/draft-ietf-httpbis-p5-range-19#section-5.4.1 + # http://tools.ietf.org/html/draft-ietf-httpbis-p5-range-19#appendix-C + # http://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-19#section-3.2.5 + unless /\Abytes=((?:,[ \t]*)*(?:\d+-\d*|-\d+)(?:[ \t]*,(?:[ \t]*\d+-\d*|-\d+)?)*)\z/ =~ value + raise Gem::Net::HTTPHeaderSyntaxError, "invalid syntax for byte-ranges-specifier: '#{value}'" + end + + byte_range_set = $1 + result = byte_range_set.split(/,/).map {|spec| + m = /(\d+)?\s*-\s*(\d+)?/i.match(spec) or + raise Gem::Net::HTTPHeaderSyntaxError, "invalid byte-range-spec: '#{spec}'" + d1 = m[1].to_i + d2 = m[2].to_i + if m[1] and m[2] + if d1 > d2 + raise Gem::Net::HTTPHeaderSyntaxError, "last-byte-pos MUST greater than or equal to first-byte-pos but '#{spec}'" + end + d1..d2 + elsif m[1] + d1..-1 + elsif m[2] + -d2..-1 + else + raise Gem::Net::HTTPHeaderSyntaxError, 'range is not specified' + end + } + # if result.empty? + # byte-range-set must include at least one byte-range-spec or suffix-byte-range-spec + # but above regexp already denies it. + if result.size == 1 && result[0].begin == 0 && result[0].end == -1 + raise Gem::Net::HTTPHeaderSyntaxError, 'only one suffix-byte-range-spec with zero suffix-length' + end + result + end + + # call-seq: + # set_range(length) -> length + # set_range(offset, length) -> range + # set_range(begin..length) -> range + # + # Sets the value for field <tt>'Range'</tt>; + # see {Range request header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#range-request-header]: + # + # With argument +length+: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.set_range(100) # => 100 + # req['Range'] # => "bytes=0-99" + # + # With arguments +offset+ and +length+: + # + # req.set_range(100, 100) # => 100...200 + # req['Range'] # => "bytes=100-199" + # + # With argument +range+: + # + # req.set_range(100..199) # => 100..199 + # req['Range'] # => "bytes=100-199" + # + # Gem::Net::HTTPHeader#range= is an alias for Gem::Net::HTTPHeader#set_range. + def set_range(r, e = nil) + unless r + @header.delete 'range' + return r + end + r = (r...r+e) if e + case r + when Numeric + n = r.to_i + rangestr = (n > 0 ? "0-#{n-1}" : "-#{-n}") + when Range + first = r.first + last = r.end + last -= 1 if r.exclude_end? + if last == -1 + rangestr = (first > 0 ? "#{first}-" : "-#{-first}") + else + raise Gem::Net::HTTPHeaderSyntaxError, 'range.first is negative' if first < 0 + raise Gem::Net::HTTPHeaderSyntaxError, 'range.last is negative' if last < 0 + raise Gem::Net::HTTPHeaderSyntaxError, 'must be .first < .last' if first > last + rangestr = "#{first}-#{last}" + end + else + raise TypeError, 'Range/Integer is required' + end + @header['range'] = ["bytes=#{rangestr}"] + r + end + + alias range= set_range + + # Returns the value of field <tt>'Content-Length'</tt> as an integer, + # or +nil+ if there is no such field; + # see {Content-Length request header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-length-request-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/nosuch/1') + # res.content_length # => 2 + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res.content_length # => nil + # + def content_length + return nil unless key?('Content-Length') + len = self['Content-Length'].slice(/\d+/) or + raise Gem::Net::HTTPHeaderSyntaxError, 'wrong Content-Length format' + len.to_i + end + + # Sets the value of field <tt>'Content-Length'</tt> to the given numeric; + # see {Content-Length response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-length-response-header]: + # + # _uri = uri.dup + # hostname = _uri.hostname # => "jsonplaceholder.typicode.com" + # _uri.path = '/posts' # => "/posts" + # req = Gem::Net::HTTP::Post.new(_uri) # => #<Gem::Net::HTTP::Post POST> + # req.body = '{"title": "foo","body": "bar","userId": 1}' + # req.content_length = req.body.size # => 42 + # req.content_type = 'application/json' + # res = Gem::Net::HTTP.start(hostname) do |http| + # http.request(req) + # end # => #<Gem::Net::HTTPCreated 201 Created readbody=true> + # + def content_length=(len) + unless len + @header.delete 'content-length' + return nil + end + @header['content-length'] = [len.to_i.to_s] + end + + # Returns +true+ if field <tt>'Transfer-Encoding'</tt> + # exists and has value <tt>'chunked'</tt>, + # +false+ otherwise; + # see {Transfer-Encoding response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#transfer-encoding-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['Transfer-Encoding'] # => "chunked" + # res.chunked? # => true + # + def chunked? + return false unless @header['transfer-encoding'] + field = self['Transfer-Encoding'] + (/(?:\A|[^\-\w])chunked(?![\-\w])/i =~ field) ? true : false + end + + # Returns a Range object representing the value of field + # <tt>'Content-Range'</tt>, or +nil+ if no such field exists; + # see {Content-Range response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-range-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['Content-Range'] # => nil + # res['Content-Range'] = 'bytes 0-499/1000' + # res['Content-Range'] # => "bytes 0-499/1000" + # res.content_range # => 0..499 + # + def content_range + return nil unless @header['content-range'] + m = %r<\A\s*(\w+)\s+(\d+)-(\d+)/(\d+|\*)>.match(self['Content-Range']) or + raise Gem::Net::HTTPHeaderSyntaxError, 'wrong Content-Range format' + return unless m[1] == 'bytes' + m[2].to_i .. m[3].to_i + end + + # Returns the integer representing length of the value of field + # <tt>'Content-Range'</tt>, or +nil+ if no such field exists; + # see {Content-Range response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-range-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['Content-Range'] # => nil + # res['Content-Range'] = 'bytes 0-499/1000' + # res.range_length # => 500 + # + def range_length + r = content_range() or return nil + r.end - r.begin + 1 + end + + # Returns the {media type}[https://en.wikipedia.org/wiki/Media_type] + # from the value of field <tt>'Content-Type'</tt>, + # or +nil+ if no such field exists; + # see {Content-Type response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-type-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['content-type'] # => "application/json; charset=utf-8" + # res.content_type # => "application/json" + # + def content_type + main = main_type() + return nil unless main + + sub = sub_type() + if sub + "#{main}/#{sub}" + else + main + end + end + + # Returns the leading ('type') part of the + # {media type}[https://en.wikipedia.org/wiki/Media_type] + # from the value of field <tt>'Content-Type'</tt>, + # or +nil+ if no such field exists; + # see {Content-Type response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-type-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['content-type'] # => "application/json; charset=utf-8" + # res.main_type # => "application" + # + def main_type + return nil unless @header['content-type'] + self['Content-Type'].split(';').first.to_s.split('/')[0].to_s.strip + end + + # Returns the trailing ('subtype') part of the + # {media type}[https://en.wikipedia.org/wiki/Media_type] + # from the value of field <tt>'Content-Type'</tt>, + # or +nil+ if no such field exists; + # see {Content-Type response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-type-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['content-type'] # => "application/json; charset=utf-8" + # res.sub_type # => "json" + # + def sub_type + return nil unless @header['content-type'] + _, sub = *self['Content-Type'].split(';').first.to_s.split('/') + return nil unless sub + sub.strip + end + + # Returns the trailing ('parameters') part of the value of field <tt>'Content-Type'</tt>, + # or +nil+ if no such field exists; + # see {Content-Type response header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-type-response-header]: + # + # res = Gem::Net::HTTP.get_response(hostname, '/todos/1') + # res['content-type'] # => "application/json; charset=utf-8" + # res.type_params # => {"charset"=>"utf-8"} + # + def type_params + result = {} + list = self['Content-Type'].to_s.split(';') + list.shift + list.each do |param| + k, v = *param.split('=', 2) + result[k.strip] = v.strip + end + result + end + + # Sets the value of field <tt>'Content-Type'</tt>; + # returns the new value; + # see {Content-Type request header}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#content-type-request-header]: + # + # req = Gem::Net::HTTP::Get.new(uri) + # req.set_content_type('application/json') # => ["application/json"] + # + # Gem::Net::HTTPHeader#content_type= is an alias for Gem::Net::HTTPHeader#set_content_type. + def set_content_type(type, params = {}) + @header['content-type'] = [type + params.map{|k,v|"; #{k}=#{v}"}.join('')] + end + + alias content_type= set_content_type + + # Sets the request body to a URL-encoded string derived from argument +params+, + # and sets request header field <tt>'Content-Type'</tt> + # to <tt>'application/x-www-form-urlencoded'</tt>. + # + # The resulting request is suitable for HTTP request +POST+ or +PUT+. + # + # Argument +params+ must be suitable for use as argument +enum+ to + # {Gem::URI.encode_www_form}[https://docs.ruby-lang.org/en/master/Gem::URI.html#method-c-encode_www_form]. + # + # With only argument +params+ given, + # sets the body to a URL-encoded string with the default separator <tt>'&'</tt>: + # + # req = Gem::Net::HTTP::Post.new('example.com') + # + # req.set_form_data(q: 'ruby', lang: 'en') + # req.body # => "q=ruby&lang=en" + # req['Content-Type'] # => "application/x-www-form-urlencoded" + # + # req.set_form_data([['q', 'ruby'], ['lang', 'en']]) + # req.body # => "q=ruby&lang=en" + # + # req.set_form_data(q: ['ruby', 'perl'], lang: 'en') + # req.body # => "q=ruby&q=perl&lang=en" + # + # req.set_form_data([['q', 'ruby'], ['q', 'perl'], ['lang', 'en']]) + # req.body # => "q=ruby&q=perl&lang=en" + # + # With string argument +sep+ also given, + # uses that string as the separator: + # + # req.set_form_data({q: 'ruby', lang: 'en'}, '|') + # req.body # => "q=ruby|lang=en" + # + # Gem::Net::HTTPHeader#form_data= is an alias for Gem::Net::HTTPHeader#set_form_data. + def set_form_data(params, sep = '&') + query = Gem::URI.encode_www_form(params) + query.gsub!(/&/, sep) if sep != '&' + self.body = query + self.content_type = 'application/x-www-form-urlencoded' + end + + alias form_data= set_form_data + + # Stores form data to be used in a +POST+ or +PUT+ request. + # + # The form data given in +params+ consists of zero or more fields; + # each field is: + # + # - A scalar value. + # - A name/value pair. + # - An IO stream opened for reading. + # + # Argument +params+ should be an + # {Enumerable}[https://docs.ruby-lang.org/en/master/Enumerable.html#module-Enumerable-label-Enumerable+in+Ruby+Classes] + # (method <tt>params.map</tt> will be called), + # and is often an array or hash. + # + # First, we set up a request: + # + # _uri = uri.dup + # _uri.path ='/posts' + # req = Gem::Net::HTTP::Post.new(_uri) + # + # <b>Argument +params+ As an Array</b> + # + # When +params+ is an array, + # each of its elements is a subarray that defines a field; + # the subarray may contain: + # + # - One string: + # + # req.set_form([['foo'], ['bar'], ['baz']]) + # + # - Two strings: + # + # req.set_form([%w[foo 0], %w[bar 1], %w[baz 2]]) + # + # - When argument +enctype+ (see below) is given as + # <tt>'multipart/form-data'</tt>: + # + # - A string name and an IO stream opened for reading: + # + # require 'stringio' + # req.set_form([['file', StringIO.new('Ruby is cool.')]]) + # + # - A string name, an IO stream opened for reading, + # and an options hash, which may contain these entries: + # + # - +:filename+: The name of the file to use. + # - +:content_type+: The content type of the uploaded file. + # + # Example: + # + # req.set_form([['file', file, {filename: "other-filename.foo"}]] + # + # The various forms may be mixed: + # + # req.set_form(['foo', %w[bar 1], ['file', file]]) + # + # <b>Argument +params+ As a Hash</b> + # + # When +params+ is a hash, + # each of its entries is a name/value pair that defines a field: + # + # - The name is a string. + # - The value may be: + # + # - +nil+. + # - Another string. + # - An IO stream opened for reading + # (only when argument +enctype+ -- see below -- is given as + # <tt>'multipart/form-data'</tt>). + # + # Examples: + # + # # Nil-valued fields. + # req.set_form({'foo' => nil, 'bar' => nil, 'baz' => nil}) + # + # # String-valued fields. + # req.set_form({'foo' => 0, 'bar' => 1, 'baz' => 2}) + # + # # IO-valued field. + # require 'stringio' + # req.set_form({'file' => StringIO.new('Ruby is cool.')}) + # + # # Mixture of fields. + # req.set_form({'foo' => nil, 'bar' => 1, 'file' => file}) + # + # Optional argument +enctype+ specifies the value to be given + # to field <tt>'Content-Type'</tt>, and must be one of: + # + # - <tt>'application/x-www-form-urlencoded'</tt> (the default). + # - <tt>'multipart/form-data'</tt>; + # see {RFC 7578}[https://www.rfc-editor.org/rfc/rfc7578]. + # + # Optional argument +formopt+ is a hash of options + # (applicable only when argument +enctype+ + # is <tt>'multipart/form-data'</tt>) + # that may include the following entries: + # + # - +:boundary+: The value is the boundary string for the multipart message. + # If not given, the boundary is a random string. + # See {Boundary}[https://www.rfc-editor.org/rfc/rfc7578#section-4.1]. + # - +:charset+: Value is the character set for the form submission. + # Field names and values of non-file fields should be encoded with this charset. + # + def set_form(params, enctype='application/x-www-form-urlencoded', formopt={}) + @body_data = params + @body = nil + @body_stream = nil + @form_option = formopt + case enctype + when /\Aapplication\/x-www-form-urlencoded\z/i, + /\Amultipart\/form-data\z/i + self.content_type = enctype + else + raise ArgumentError, "invalid enctype: #{enctype}" + end + end + + # Sets header <tt>'Authorization'</tt> using the given + # +account+ and +password+ strings: + # + # req.basic_auth('my_account', 'my_password') + # req['Authorization'] + # # => "Basic bXlfYWNjb3VudDpteV9wYXNzd29yZA==" + # + def basic_auth(account, password) + @header['authorization'] = [basic_encode(account, password)] + end + + # Sets header <tt>'Proxy-Authorization'</tt> using the given + # +account+ and +password+ strings: + # + # req.proxy_basic_auth('my_account', 'my_password') + # req['Proxy-Authorization'] + # # => "Basic bXlfYWNjb3VudDpteV9wYXNzd29yZA==" + # + def proxy_basic_auth(account, password) + @header['proxy-authorization'] = [basic_encode(account, password)] + end + + def basic_encode(account, password) # :nodoc: + 'Basic ' + ["#{account}:#{password}"].pack('m0') + end + private :basic_encode + + # Returns whether the HTTP session is to be closed. + def connection_close? + token = /(?:\A|,)\s*close\s*(?:\z|,)/i + @header['connection']&.grep(token) {return true} + @header['proxy-connection']&.grep(token) {return true} + false + end + + # Returns whether the HTTP session is to be kept alive. + def connection_keep_alive? + token = /(?:\A|,)\s*keep-alive\s*(?:\z|,)/i + @header['connection']&.grep(token) {return true} + @header['proxy-connection']&.grep(token) {return true} + false + end + +end diff --git a/lib/rubygems/vendor/net-http/lib/net/http/proxy_delta.rb b/lib/rubygems/vendor/net-http/lib/net/http/proxy_delta.rb new file mode 100644 index 0000000000..137295a883 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/proxy_delta.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +module Gem::Net::HTTP::ProxyDelta #:nodoc: internal use only + private + + def conn_address + proxy_address() + end + + def conn_port + proxy_port() + end + + def edit_path(path) + use_ssl? ? path : "http://#{addr_port()}#{path}" + end +end + diff --git a/lib/rubygems/vendor/net-http/lib/net/http/request.rb b/lib/rubygems/vendor/net-http/lib/net/http/request.rb new file mode 100644 index 0000000000..495ec9be54 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/request.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# This class is the base class for \Gem::Net::HTTP request classes. +# The class should not be used directly; +# instead you should use its subclasses, listed below. +# +# == Creating a Request +# +# An request object may be created with either a Gem::URI or a string hostname: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('https://jsonplaceholder.typicode.com/') +# req = Gem::Net::HTTP::Get.new(uri) # => #<Gem::Net::HTTP::Get GET> +# req = Gem::Net::HTTP::Get.new(uri.hostname) # => #<Gem::Net::HTTP::Get GET> +# +# And with any of the subclasses: +# +# req = Gem::Net::HTTP::Head.new(uri) # => #<Gem::Net::HTTP::Head HEAD> +# req = Gem::Net::HTTP::Post.new(uri) # => #<Gem::Net::HTTP::Post POST> +# req = Gem::Net::HTTP::Put.new(uri) # => #<Gem::Net::HTTP::Put PUT> +# # ... +# +# The new instance is suitable for use as the argument to Gem::Net::HTTP#request. +# +# == Request Headers +# +# A new request object has these header fields by default: +# +# req.to_hash +# # => +# {"accept-encoding"=>["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"], +# "accept"=>["*/*"], +# "user-agent"=>["Ruby"], +# "host"=>["jsonplaceholder.typicode.com"]} +# +# See: +# +# - {Request header Accept-Encoding}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Accept-Encoding] +# and {Compression and Decompression}[rdoc-ref:Gem::Net::HTTP@Compression+and+Decompression]. +# - {Request header Accept}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#accept-request-header]. +# - {Request header User-Agent}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#user-agent-request-header]. +# - {Request header Host}[https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#host-request-header]. +# +# You can add headers or override default headers: +# +# # res = Gem::Net::HTTP::Get.new(uri, {'foo' => '0', 'bar' => '1'}) +# +# This class (and therefore its subclasses) also includes (indirectly) +# module Gem::Net::HTTPHeader, which gives access to its +# {methods for setting headers}[rdoc-ref:Gem::Net::HTTPHeader@Setters]. +# +# == Request Subclasses +# +# Subclasses for HTTP requests: +# +# - Gem::Net::HTTP::Get +# - Gem::Net::HTTP::Head +# - Gem::Net::HTTP::Post +# - Gem::Net::HTTP::Put +# - Gem::Net::HTTP::Delete +# - Gem::Net::HTTP::Options +# - Gem::Net::HTTP::Trace +# - Gem::Net::HTTP::Patch +# +# Subclasses for WebDAV requests: +# +# - Gem::Net::HTTP::Propfind +# - Gem::Net::HTTP::Proppatch +# - Gem::Net::HTTP::Mkcol +# - Gem::Net::HTTP::Copy +# - Gem::Net::HTTP::Move +# - Gem::Net::HTTP::Lock +# - Gem::Net::HTTP::Unlock +# +class Gem::Net::HTTPRequest < Gem::Net::HTTPGenericRequest + # Creates an HTTP request object for +path+. + # + # +initheader+ are the default headers to use. Gem::Net::HTTP adds + # Accept-Encoding to enable compression of the response body unless + # Accept-Encoding or Range are supplied in +initheader+. + + def initialize(path, initheader = nil) + super self.class::METHOD, + self.class::REQUEST_HAS_BODY, + self.class::RESPONSE_HAS_BODY, + path, initheader + end +end diff --git a/lib/rubygems/vendor/net-http/lib/net/http/requests.rb b/lib/rubygems/vendor/net-http/lib/net/http/requests.rb new file mode 100644 index 0000000000..f990761042 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/requests.rb @@ -0,0 +1,444 @@ +# frozen_string_literal: true + +# HTTP/1.1 methods --- RFC2616 + +# \Class for representing +# {HTTP method GET}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#GET_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Get.new(uri) # => #<Gem::Net::HTTP::Get GET> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: optional. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: yes. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: yes. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: yes. +# +# Related: +# +# - Gem::Net::HTTP.get: sends +GET+ request, returns response body. +# - Gem::Net::HTTP#get: sends +GET+ request, returns response object. +# +class Gem::Net::HTTP::Get < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'GET' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {HTTP method HEAD}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#HEAD_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Head.new(uri) # => #<Gem::Net::HTTP::Head HEAD> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: optional. +# - Response body: no. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: yes. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: yes. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: yes. +# +# Related: +# +# - Gem::Net::HTTP#head: sends +HEAD+ request, returns response object. +# +class Gem::Net::HTTP::Head < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'HEAD' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = false +end + +# \Class for representing +# {HTTP method POST}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#POST_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# uri.path = '/posts' +# req = Gem::Net::HTTP::Post.new(uri) # => #<Gem::Net::HTTP::Post POST> +# req.body = '{"title": "foo","body": "bar","userId": 1}' +# req.content_type = 'application/json' +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: yes. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: no. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: no. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: yes. +# +# Related: +# +# - Gem::Net::HTTP.post: sends +POST+ request, returns response object. +# - Gem::Net::HTTP#post: sends +POST+ request, returns response object. +# +class Gem::Net::HTTP::Post < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'POST' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {HTTP method PUT}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#PUT_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# uri.path = '/posts' +# req = Gem::Net::HTTP::Put.new(uri) # => #<Gem::Net::HTTP::Put PUT> +# req.body = '{"title": "foo","body": "bar","userId": 1}' +# req.content_type = 'application/json' +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: yes. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: no. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: yes. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: no. +# +# Related: +# +# - Gem::Net::HTTP.put: sends +PUT+ request, returns response object. +# - Gem::Net::HTTP#put: sends +PUT+ request, returns response object. +# +class Gem::Net::HTTP::Put < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'PUT' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {HTTP method DELETE}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#DELETE_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# uri.path = '/posts/1' +# req = Gem::Net::HTTP::Delete.new(uri) # => #<Gem::Net::HTTP::Delete DELETE> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: optional. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: no. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: yes. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: no. +# +# Related: +# +# - Gem::Net::HTTP#delete: sends +DELETE+ request, returns response object. +# +class Gem::Net::HTTP::Delete < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'DELETE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {HTTP method OPTIONS}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#OPTIONS_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Options.new(uri) # => #<Gem::Net::HTTP::Options OPTIONS> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: optional. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: yes. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: yes. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: no. +# +# Related: +# +# - Gem::Net::HTTP#options: sends +OPTIONS+ request, returns response object. +# +class Gem::Net::HTTP::Options < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'OPTIONS' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {HTTP method TRACE}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#TRACE_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Trace.new(uri) # => #<Gem::Net::HTTP::Trace TRACE> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: no. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: yes. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: yes. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: no. +# +# Related: +# +# - Gem::Net::HTTP#trace: sends +TRACE+ request, returns response object. +# +class Gem::Net::HTTP::Trace < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'TRACE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {HTTP method PATCH}[https://en.wikipedia.org/w/index.php?title=Hypertext_Transfer_Protocol#PATCH_method]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# uri.path = '/posts' +# req = Gem::Net::HTTP::Patch.new(uri) # => #<Gem::Net::HTTP::Patch PATCH> +# req.body = '{"title": "foo","body": "bar","userId": 1}' +# req.content_type = 'application/json' +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Properties: +# +# - Request body: yes. +# - Response body: yes. +# - {Safe}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Safe_methods]: no. +# - {Idempotent}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Idempotent_methods]: no. +# - {Cacheable}[https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Cacheable_methods]: no. +# +# Related: +# +# - Gem::Net::HTTP#patch: sends +PATCH+ request, returns response object. +# +class Gem::Net::HTTP::Patch < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'PATCH' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# +# WebDAV methods --- RFC2518 +# + +# \Class for representing +# {WebDAV method PROPFIND}[http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Propfind.new(uri) # => #<Gem::Net::HTTP::Propfind PROPFIND> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#propfind: sends +PROPFIND+ request, returns response object. +# +class Gem::Net::HTTP::Propfind < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'PROPFIND' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {WebDAV method PROPPATCH}[http://www.webdav.org/specs/rfc4918.html#METHOD_PROPPATCH]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Proppatch.new(uri) # => #<Gem::Net::HTTP::Proppatch PROPPATCH> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#proppatch: sends +PROPPATCH+ request, returns response object. +# +class Gem::Net::HTTP::Proppatch < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'PROPPATCH' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {WebDAV method MKCOL}[http://www.webdav.org/specs/rfc4918.html#METHOD_MKCOL]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Mkcol.new(uri) # => #<Gem::Net::HTTP::Mkcol MKCOL> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#mkcol: sends +MKCOL+ request, returns response object. +# +class Gem::Net::HTTP::Mkcol < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'MKCOL' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {WebDAV method COPY}[http://www.webdav.org/specs/rfc4918.html#METHOD_COPY]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Copy.new(uri) # => #<Gem::Net::HTTP::Copy COPY> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#copy: sends +COPY+ request, returns response object. +# +class Gem::Net::HTTP::Copy < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'COPY' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {WebDAV method MOVE}[http://www.webdav.org/specs/rfc4918.html#METHOD_MOVE]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Move.new(uri) # => #<Gem::Net::HTTP::Move MOVE> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#move: sends +MOVE+ request, returns response object. +# +class Gem::Net::HTTP::Move < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'MOVE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {WebDAV method LOCK}[http://www.webdav.org/specs/rfc4918.html#METHOD_LOCK]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Lock.new(uri) # => #<Gem::Net::HTTP::Lock LOCK> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#lock: sends +LOCK+ request, returns response object. +# +class Gem::Net::HTTP::Lock < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'LOCK' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# \Class for representing +# {WebDAV method UNLOCK}[http://www.webdav.org/specs/rfc4918.html#METHOD_UNLOCK]: +# +# require 'rubygems/vendor/net-http/lib/net/http' +# uri = Gem::URI('http://example.com') +# hostname = uri.hostname # => "example.com" +# req = Gem::Net::HTTP::Unlock.new(uri) # => #<Gem::Net::HTTP::Unlock UNLOCK> +# res = Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end +# +# See {Request Headers}[rdoc-ref:Gem::Net::HTTPRequest@Request+Headers]. +# +# Related: +# +# - Gem::Net::HTTP#unlock: sends +UNLOCK+ request, returns response object. +# +class Gem::Net::HTTP::Unlock < Gem::Net::HTTPRequest + # :stopdoc: + METHOD = 'UNLOCK' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end diff --git a/lib/rubygems/vendor/net-http/lib/net/http/response.rb b/lib/rubygems/vendor/net-http/lib/net/http/response.rb new file mode 100644 index 0000000000..dc164f1504 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/response.rb @@ -0,0 +1,739 @@ +# frozen_string_literal: true + +# This class is the base class for \Gem::Net::HTTP response classes. +# +# == About the Examples +# +# :include: doc/net-http/examples.rdoc +# +# == Returned Responses +# +# \Method Gem::Net::HTTP.get_response returns +# an instance of one of the subclasses of \Gem::Net::HTTPResponse: +# +# Gem::Net::HTTP.get_response(uri) +# # => #<Gem::Net::HTTPOK 200 OK readbody=true> +# Gem::Net::HTTP.get_response(hostname, '/nosuch') +# # => #<Gem::Net::HTTPNotFound 404 Not Found readbody=true> +# +# As does method Gem::Net::HTTP#request: +# +# req = Gem::Net::HTTP::Get.new(uri) +# Gem::Net::HTTP.start(hostname) do |http| +# http.request(req) +# end # => #<Gem::Net::HTTPOK 200 OK readbody=true> +# +# \Class \Gem::Net::HTTPResponse includes module Gem::Net::HTTPHeader, +# which provides access to response header values via (among others): +# +# - \Hash-like method <tt>[]</tt>. +# - Specific reader methods, such as +content_type+. +# +# Examples: +# +# res = Gem::Net::HTTP.get_response(uri) # => #<Gem::Net::HTTPOK 200 OK readbody=true> +# res['Content-Type'] # => "text/html; charset=UTF-8" +# res.content_type # => "text/html" +# +# == Response Subclasses +# +# \Class \Gem::Net::HTTPResponse has a subclass for each +# {HTTP status code}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes]. +# You can look up the response class for a given code: +# +# Gem::Net::HTTPResponse::CODE_TO_OBJ['200'] # => Gem::Net::HTTPOK +# Gem::Net::HTTPResponse::CODE_TO_OBJ['400'] # => Gem::Net::HTTPBadRequest +# Gem::Net::HTTPResponse::CODE_TO_OBJ['404'] # => Gem::Net::HTTPNotFound +# +# And you can retrieve the status code for a response object: +# +# Gem::Net::HTTP.get_response(uri).code # => "200" +# Gem::Net::HTTP.get_response(hostname, '/nosuch').code # => "404" +# +# The response subclasses (indentation shows class hierarchy): +# +# - Gem::Net::HTTPUnknownResponse (for unhandled \HTTP extensions). +# +# - Gem::Net::HTTPInformation: +# +# - Gem::Net::HTTPContinue (100) +# - Gem::Net::HTTPSwitchProtocol (101) +# - Gem::Net::HTTPProcessing (102) +# - Gem::Net::HTTPEarlyHints (103) +# +# - Gem::Net::HTTPSuccess: +# +# - Gem::Net::HTTPOK (200) +# - Gem::Net::HTTPCreated (201) +# - Gem::Net::HTTPAccepted (202) +# - Gem::Net::HTTPNonAuthoritativeInformation (203) +# - Gem::Net::HTTPNoContent (204) +# - Gem::Net::HTTPResetContent (205) +# - Gem::Net::HTTPPartialContent (206) +# - Gem::Net::HTTPMultiStatus (207) +# - Gem::Net::HTTPAlreadyReported (208) +# - Gem::Net::HTTPIMUsed (226) +# +# - Gem::Net::HTTPRedirection: +# +# - Gem::Net::HTTPMultipleChoices (300) +# - Gem::Net::HTTPMovedPermanently (301) +# - Gem::Net::HTTPFound (302) +# - Gem::Net::HTTPSeeOther (303) +# - Gem::Net::HTTPNotModified (304) +# - Gem::Net::HTTPUseProxy (305) +# - Gem::Net::HTTPTemporaryRedirect (307) +# - Gem::Net::HTTPPermanentRedirect (308) +# +# - Gem::Net::HTTPClientError: +# +# - Gem::Net::HTTPBadRequest (400) +# - Gem::Net::HTTPUnauthorized (401) +# - Gem::Net::HTTPPaymentRequired (402) +# - Gem::Net::HTTPForbidden (403) +# - Gem::Net::HTTPNotFound (404) +# - Gem::Net::HTTPMethodNotAllowed (405) +# - Gem::Net::HTTPNotAcceptable (406) +# - Gem::Net::HTTPProxyAuthenticationRequired (407) +# - Gem::Net::HTTPRequestTimeOut (408) +# - Gem::Net::HTTPConflict (409) +# - Gem::Net::HTTPGone (410) +# - Gem::Net::HTTPLengthRequired (411) +# - Gem::Net::HTTPPreconditionFailed (412) +# - Gem::Net::HTTPRequestEntityTooLarge (413) +# - Gem::Net::HTTPRequestURITooLong (414) +# - Gem::Net::HTTPUnsupportedMediaType (415) +# - Gem::Net::HTTPRequestedRangeNotSatisfiable (416) +# - Gem::Net::HTTPExpectationFailed (417) +# - Gem::Net::HTTPMisdirectedRequest (421) +# - Gem::Net::HTTPUnprocessableEntity (422) +# - Gem::Net::HTTPLocked (423) +# - Gem::Net::HTTPFailedDependency (424) +# - Gem::Net::HTTPUpgradeRequired (426) +# - Gem::Net::HTTPPreconditionRequired (428) +# - Gem::Net::HTTPTooManyRequests (429) +# - Gem::Net::HTTPRequestHeaderFieldsTooLarge (431) +# - Gem::Net::HTTPUnavailableForLegalReasons (451) +# +# - Gem::Net::HTTPServerError: +# +# - Gem::Net::HTTPInternalServerError (500) +# - Gem::Net::HTTPNotImplemented (501) +# - Gem::Net::HTTPBadGateway (502) +# - Gem::Net::HTTPServiceUnavailable (503) +# - Gem::Net::HTTPGatewayTimeOut (504) +# - Gem::Net::HTTPVersionNotSupported (505) +# - Gem::Net::HTTPVariantAlsoNegotiates (506) +# - Gem::Net::HTTPInsufficientStorage (507) +# - Gem::Net::HTTPLoopDetected (508) +# - Gem::Net::HTTPNotExtended (510) +# - Gem::Net::HTTPNetworkAuthenticationRequired (511) +# +# There is also the Gem::Net::HTTPBadResponse exception which is raised when +# there is a protocol error. +# +class Gem::Net::HTTPResponse + class << self + # true if the response has a body. + def body_permitted? + self::HAS_BODY + end + + def exception_type # :nodoc: internal use only + self::EXCEPTION_TYPE + end + + def read_new(sock) #:nodoc: internal use only + httpv, code, msg = read_status_line(sock) + res = response_class(code).new(httpv, code, msg) + each_response_header(sock) do |k,v| + res.add_field k, v + end + res + end + + private + # :stopdoc: + + def read_status_line(sock) + str = sock.readline + m = /\AHTTP(?:\/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?\z/in.match(str) or + raise Gem::Net::HTTPBadResponse, "wrong status line: #{str.dump}" + m.captures + end + + def response_class(code) + CODE_TO_OBJ[code] or + CODE_CLASS_TO_OBJ[code[0,1]] or + Gem::Net::HTTPUnknownResponse + end + + def each_response_header(sock) + key = value = nil + while true + line = sock.readuntil("\n", true).sub(/\s+\z/, '') + break if line.empty? + if line[0] == ?\s or line[0] == ?\t and value + value << ' ' unless value.empty? + value << line.strip + else + yield key, value if key + key, value = line.strip.split(/\s*:\s*/, 2) + raise Gem::Net::HTTPBadResponse, 'wrong header line format' if value.nil? + end + end + yield key, value if key + end + end + + # next is to fix bug in RDoc, where the private inside class << self + # spills out. + public + + include Gem::Net::HTTPHeader + + def initialize(httpv, code, msg) #:nodoc: internal use only + @http_version = httpv + @code = code + @message = msg + initialize_http_header nil + @body = nil + @read = false + @uri = nil + @decode_content = false + @body_encoding = false + @ignore_eof = true + end + + # The HTTP version supported by the server. + attr_reader :http_version + + # The HTTP result code string. For example, '302'. You can also + # determine the response type by examining which response subclass + # the response object is an instance of. + attr_reader :code + + # The HTTP result message sent by the server. For example, 'Not Found'. + attr_reader :message + alias msg message # :nodoc: obsolete + + # The Gem::URI used to fetch this response. The response Gem::URI is only available + # if a Gem::URI was used to create the request. + attr_reader :uri + + # Set to true automatically when the request did not contain an + # Accept-Encoding header from the user. + attr_accessor :decode_content + + # Returns the value set by body_encoding=, or +false+ if none; + # see #body_encoding=. + attr_reader :body_encoding + + # Sets the encoding that should be used when reading the body: + # + # - If the given value is an Encoding object, that encoding will be used. + # - Otherwise if the value is a string, the value of + # {Encoding#find(value)}[https://docs.ruby-lang.org/en/master/Encoding.html#method-c-find] + # will be used. + # - Otherwise an encoding will be deduced from the body itself. + # + # Examples: + # + # http = Gem::Net::HTTP.new(hostname) + # req = Gem::Net::HTTP::Get.new('/') + # + # http.request(req) do |res| + # p res.body.encoding # => #<Encoding:ASCII-8BIT> + # end + # + # http.request(req) do |res| + # res.body_encoding = "UTF-8" + # p res.body.encoding # => #<Encoding:UTF-8> + # end + # + def body_encoding=(value) + value = Encoding.find(value) if value.is_a?(String) + @body_encoding = value + end + + # Whether to ignore EOF when reading bodies with a specified Content-Length + # header. + attr_accessor :ignore_eof + + def inspect # :nodoc: + "#<#{self.class} #{@code} #{@message} readbody=#{@read}>" + end + + # + # response <-> exception relationship + # + + def code_type #:nodoc: + self.class + end + + def error! #:nodoc: + message = @code + message = "#{message} #{@message.dump}" if @message + raise error_type().new(message, self) + end + + def error_type #:nodoc: + self.class::EXCEPTION_TYPE + end + + # Raises an HTTP error if the response is not 2xx (success). + def value + error! unless self.kind_of?(Gem::Net::HTTPSuccess) + end + + def uri= uri # :nodoc: + @uri = uri.dup if uri + end + + # + # header (for backward compatibility only; DO NOT USE) + # + + def response #:nodoc: + warn "Gem::Net::HTTPResponse#response is obsolete", uplevel: 1 if $VERBOSE + self + end + + def header #:nodoc: + warn "Gem::Net::HTTPResponse#header is obsolete", uplevel: 1 if $VERBOSE + self + end + + def read_header #:nodoc: + warn "Gem::Net::HTTPResponse#read_header is obsolete", uplevel: 1 if $VERBOSE + self + end + + # + # body + # + + def reading_body(sock, reqmethodallowbody) #:nodoc: internal use only + @socket = sock + @body_exist = reqmethodallowbody && self.class.body_permitted? + begin + yield + self.body # ensure to read body + ensure + @socket = nil + end + end + + # Gets the entity body returned by the remote HTTP server. + # + # If a block is given, the body is passed to the block, and + # the body is provided in fragments, as it is read in from the socket. + # + # If +dest+ argument is given, response is read into that variable, + # with <code>dest#<<</code> method (it could be String or IO, or any + # other object responding to <code><<</code>). + # + # Calling this method a second or subsequent time for the same + # HTTPResponse object will return the value already read. + # + # http.request_get('/index.html') {|res| + # puts res.read_body + # } + # + # http.request_get('/index.html') {|res| + # p res.read_body.object_id # 538149362 + # p res.read_body.object_id # 538149362 + # } + # + # # using iterator + # http.request_get('/index.html') {|res| + # res.read_body do |segment| + # print segment + # end + # } + # + def read_body(dest = nil, &block) + if @read + raise IOError, "#{self.class}\#read_body called twice" if dest or block + return @body + end + to = procdest(dest, block) + stream_check + if @body_exist + read_body_0 to + @body = to + else + @body = nil + end + @read = true + return if @body.nil? + + case enc = @body_encoding + when Encoding, false, nil + # Encoding: force given encoding + # false/nil: do not force encoding + else + # other value: detect encoding from body + enc = detect_encoding(@body) + end + + @body.force_encoding(enc) if enc + + @body + end + + # Returns the string response body; + # note that repeated calls for the unmodified body return a cached string: + # + # path = '/todos/1' + # Gem::Net::HTTP.start(hostname) do |http| + # res = http.get(path) + # p res.body + # p http.head(path).body # No body. + # end + # + # Output: + # + # "{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"delectus aut autem\",\n \"completed\": false\n}" + # nil + # + def body + read_body() + end + + # Sets the body of the response to the given value. + def body=(value) + @body = value + end + + alias entity body #:nodoc: obsolete + + private + + # :nodoc: + def detect_encoding(str, encoding=nil) + if encoding + elsif encoding = type_params['charset'] + elsif encoding = check_bom(str) + else + encoding = case content_type&.downcase + when %r{text/x(?:ht)?ml|application/(?:[^+]+\+)?xml} + /\A<xml[ \t\r\n]+ + version[ \t\r\n]*=[ \t\r\n]*(?:"[0-9.]+"|'[0-9.]*')[ \t\r\n]+ + encoding[ \t\r\n]*=[ \t\r\n]* + (?:"([A-Za-z][\-A-Za-z0-9._]*)"|'([A-Za-z][\-A-Za-z0-9._]*)')/x =~ str + encoding = $1 || $2 || Encoding::UTF_8 + when %r{text/html.*} + sniff_encoding(str) + end + end + return encoding + end + + # :nodoc: + def sniff_encoding(str, encoding=nil) + # the encoding sniffing algorithm + # http://www.w3.org/TR/html5/parsing.html#determining-the-character-encoding + if enc = scanning_meta(str) + enc + # 6. last visited page or something + # 7. frequency + elsif str.ascii_only? + Encoding::US_ASCII + elsif str.dup.force_encoding(Encoding::UTF_8).valid_encoding? + Encoding::UTF_8 + end + # 8. implementation-defined or user-specified + end + + # :nodoc: + def check_bom(str) + case str.byteslice(0, 2) + when "\xFE\xFF" + return Encoding::UTF_16BE + when "\xFF\xFE" + return Encoding::UTF_16LE + end + if "\xEF\xBB\xBF" == str.byteslice(0, 3) + return Encoding::UTF_8 + end + nil + end + + # :nodoc: + def scanning_meta(str) + require 'strscan' + ss = StringScanner.new(str) + if ss.scan_until(/<meta[\t\n\f\r ]*/) + attrs = {} # attribute_list + got_pragma = false + need_pragma = nil + charset = nil + + # step: Attributes + while attr = get_attribute(ss) + name, value = *attr + next if attrs[name] + attrs[name] = true + case name + when 'http-equiv' + got_pragma = true if value == 'content-type' + when 'content' + encoding = extracting_encodings_from_meta_elements(value) + unless charset + charset = encoding + end + need_pragma = true + when 'charset' + need_pragma = false + charset = value + end + end + + # step: Processing + return if need_pragma.nil? + return if need_pragma && !got_pragma + + charset = Encoding.find(charset) rescue nil + return unless charset + charset = Encoding::UTF_8 if charset == Encoding::UTF_16 + return charset # tentative + end + nil + end + + def get_attribute(ss) + ss.scan(/[\t\n\f\r \/]*/) + if ss.peek(1) == '>' + ss.getch + return nil + end + name = ss.scan(/[^=\t\n\f\r \/>]*/) + name.downcase! + raise if name.empty? + ss.skip(/[\t\n\f\r ]*/) + if ss.getch != '=' + value = '' + return [name, value] + end + ss.skip(/[\t\n\f\r ]*/) + case ss.peek(1) + when '"' + ss.getch + value = ss.scan(/[^"]+/) + value.downcase! + ss.getch + when "'" + ss.getch + value = ss.scan(/[^']+/) + value.downcase! + ss.getch + when '>' + value = '' + else + value = ss.scan(/[^\t\n\f\r >]+/) + value.downcase! + end + [name, value] + end + + def extracting_encodings_from_meta_elements(value) + # http://dev.w3.org/html5/spec/fetching-resources.html#algorithm-for-extracting-an-encoding-from-a-meta-element + if /charset[\t\n\f\r ]*=(?:"([^"]*)"|'([^']*)'|["']|\z|([^\t\n\f\r ;]+))/i =~ value + return $1 || $2 || $3 + end + return nil + end + + ## + # Checks for a supported Content-Encoding header and yields an Inflate + # wrapper for this response's socket when zlib is present. If the + # Content-Encoding is not supported or zlib is missing, the plain socket is + # yielded. + # + # If a Content-Range header is present, a plain socket is yielded as the + # bytes in the range may not be a complete deflate block. + + def inflater # :nodoc: + return yield @socket unless Gem::Net::HTTP::HAVE_ZLIB + return yield @socket unless @decode_content + return yield @socket if self['content-range'] + + v = self['content-encoding'] + case v&.downcase + when 'deflate', 'gzip', 'x-gzip' then + self.delete 'content-encoding' + + inflate_body_io = Inflater.new(@socket) + + begin + yield inflate_body_io + success = true + ensure + begin + inflate_body_io.finish + if self['content-length'] + self['content-length'] = inflate_body_io.bytes_inflated.to_s + end + rescue => err + # Ignore #finish's error if there is an exception from yield + raise err if success + end + end + when 'none', 'identity' then + self.delete 'content-encoding' + + yield @socket + else + yield @socket + end + end + + def read_body_0(dest) + inflater do |inflate_body_io| + if chunked? + read_chunked dest, inflate_body_io + return + end + + @socket = inflate_body_io + + clen = content_length() + if clen + @socket.read clen, dest, @ignore_eof + return + end + clen = range_length() + if clen + @socket.read clen, dest + return + end + @socket.read_all dest + end + end + + ## + # read_chunked reads from +@socket+ for chunk-size, chunk-extension, CRLF, + # etc. and +chunk_data_io+ for chunk-data which may be deflate or gzip + # encoded. + # + # See RFC 2616 section 3.6.1 for definitions + + def read_chunked(dest, chunk_data_io) # :nodoc: + total = 0 + while true + line = @socket.readline + hexlen = line.slice(/[0-9a-fA-F]+/) or + raise Gem::Net::HTTPBadResponse, "wrong chunk size line: #{line}" + len = hexlen.hex + break if len == 0 + begin + chunk_data_io.read len, dest + ensure + total += len + @socket.read 2 # \r\n + end + end + until @socket.readline.empty? + # none + end + end + + def stream_check + raise IOError, 'attempt to read body out of block' if @socket.nil? || @socket.closed? + end + + def procdest(dest, block) + raise ArgumentError, 'both arg and block given for HTTP method' if + dest and block + if block + Gem::Net::ReadAdapter.new(block) + else + dest || +'' + end + end + + ## + # Inflater is a wrapper around Gem::Net::BufferedIO that transparently inflates + # zlib and gzip streams. + + class Inflater # :nodoc: + + ## + # Creates a new Inflater wrapping +socket+ + + def initialize socket + @socket = socket + # zlib with automatic gzip detection + @inflate = Zlib::Inflate.new(32 + Zlib::MAX_WBITS) + end + + ## + # Finishes the inflate stream. + + def finish + return if @inflate.total_in == 0 + @inflate.finish + end + + ## + # The number of bytes inflated, used to update the Content-Length of + # the response. + + def bytes_inflated + @inflate.total_out + end + + ## + # Returns a Gem::Net::ReadAdapter that inflates each read chunk into +dest+. + # + # This allows a large response body to be inflated without storing the + # entire body in memory. + + def inflate_adapter(dest) + if dest.respond_to?(:set_encoding) + dest.set_encoding(Encoding::ASCII_8BIT) + elsif dest.respond_to?(:force_encoding) + dest.force_encoding(Encoding::ASCII_8BIT) + end + block = proc do |compressed_chunk| + @inflate.inflate(compressed_chunk) do |chunk| + compressed_chunk.clear + dest << chunk + end + end + + Gem::Net::ReadAdapter.new(block) + end + + ## + # Reads +clen+ bytes from the socket, inflates them, then writes them to + # +dest+. +ignore_eof+ is passed down to Gem::Net::BufferedIO#read + # + # Unlike Gem::Net::BufferedIO#read, this method returns more than +clen+ bytes. + # At this time there is no way for a user of Gem::Net::HTTPResponse to read a + # specific number of bytes from the HTTP response body, so this internal + # API does not return the same number of bytes as were requested. + # + # See https://bugs.ruby-lang.org/issues/6492 for further discussion. + + def read clen, dest, ignore_eof = false + temp_dest = inflate_adapter(dest) + + @socket.read clen, temp_dest, ignore_eof + end + + ## + # Reads the rest of the socket, inflates it, then writes it to +dest+. + + def read_all dest + temp_dest = inflate_adapter(dest) + + @socket.read_all temp_dest + end + + end + +end + diff --git a/lib/rubygems/vendor/net-http/lib/net/http/responses.rb b/lib/rubygems/vendor/net-http/lib/net/http/responses.rb new file mode 100644 index 0000000000..62ce1cba1b --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/responses.rb @@ -0,0 +1,1242 @@ +# frozen_string_literal: true +#-- +# https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + +module Gem::Net + + # Unknown HTTP response + class HTTPUnknownResponse < HTTPResponse + # :stopdoc: + HAS_BODY = true + EXCEPTION_TYPE = HTTPError # + end + + # Parent class for informational (1xx) HTTP response classes. + # + # An informational response indicates that the request was received and understood. + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#status.1xx]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#1xx_informational_response]. + # + class HTTPInformation < HTTPResponse + # :stopdoc: + HAS_BODY = false + EXCEPTION_TYPE = HTTPError # + end + + # Parent class for success (2xx) HTTP response classes. + # + # A success response indicates the action requested by the client + # was received, understood, and accepted. + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#status.2xx]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_success]. + # + class HTTPSuccess < HTTPResponse + # :stopdoc: + HAS_BODY = true + EXCEPTION_TYPE = HTTPError # + end + + # Parent class for redirection (3xx) HTTP response classes. + # + # A redirection response indicates the client must take additional action + # to complete the request. + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#status.3xx]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#3xx_redirection]. + # + class HTTPRedirection < HTTPResponse + # :stopdoc: + HAS_BODY = true + EXCEPTION_TYPE = HTTPRetriableError # + end + + # Parent class for client error (4xx) HTTP response classes. + # + # A client error response indicates that the client may have caused an error. + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#status.4xx]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_client_errors]. + # + class HTTPClientError < HTTPResponse + # :stopdoc: + HAS_BODY = true + EXCEPTION_TYPE = HTTPClientException # + end + + # Parent class for server error (5xx) HTTP response classes. + # + # A server error response indicates that the server failed to fulfill a request. + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#status.5xx]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#5xx_server_errors]. + # + class HTTPServerError < HTTPResponse + # :stopdoc: + HAS_BODY = true + EXCEPTION_TYPE = HTTPFatalError # + end + + # Response class for +Continue+ responses (status code 100). + # + # A +Continue+ response indicates that the server has received the request headers. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/100]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-100-continue]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#100]. + # + class HTTPContinue < HTTPInformation + # :stopdoc: + HAS_BODY = false + end + + # Response class for <tt>Switching Protocol</tt> responses (status code 101). + # + # The <tt>Switching Protocol<tt> response indicates that the server has received + # a request to switch protocols, and has agreed to do so. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/101]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-101-switching-protocols]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#101]. + # + class HTTPSwitchProtocol < HTTPInformation + # :stopdoc: + HAS_BODY = false + end + + # Response class for +Processing+ responses (status code 102). + # + # The +Processing+ response indicates that the server has received + # and is processing the request, but no response is available yet. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 2518}[https://www.rfc-editor.org/rfc/rfc2518#section-10.1]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#102]. + # + class HTTPProcessing < HTTPInformation + # :stopdoc: + HAS_BODY = false + end + + # Response class for <tt>Early Hints</tt> responses (status code 103). + # + # The <tt>Early Hints</tt> indicates that the server has received + # and is processing the request, and contains certain headers; + # the final response is not available yet. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/103]. + # - {RFC 8297}[https://www.rfc-editor.org/rfc/rfc8297.html#section-2]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#103]. + # + class HTTPEarlyHints < HTTPInformation + # :stopdoc: + HAS_BODY = false + end + + # Response class for +OK+ responses (status code 200). + # + # The +OK+ response indicates that the server has received + # a request and has responded successfully. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/200]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-200-ok]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#200]. + # + class HTTPOK < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for +Created+ responses (status code 201). + # + # The +Created+ response indicates that the server has received + # and has fulfilled a request to create a new resource. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/201]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-201-created]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#201]. + # + class HTTPCreated < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for +Accepted+ responses (status code 202). + # + # The +Accepted+ response indicates that the server has received + # and is processing a request, but the processing has not yet been completed. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/202]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-202-accepted]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#202]. + # + class HTTPAccepted < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Non-Authoritative Information</tt> responses (status code 203). + # + # The <tt>Non-Authoritative Information</tt> response indicates that the server + # is a transforming proxy (such as a Web accelerator) + # that received a 200 OK response from its origin, + # and is returning a modified version of the origin's response. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/203]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-203-non-authoritative-infor]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#203]. + # + class HTTPNonAuthoritativeInformation < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>No Content</tt> responses (status code 204). + # + # The <tt>No Content</tt> response indicates that the server + # successfully processed the request, and is not returning any content. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#204]. + # + class HTTPNoContent < HTTPSuccess + # :stopdoc: + HAS_BODY = false + end + + # Response class for <tt>Reset Content</tt> responses (status code 205). + # + # The <tt>Reset Content</tt> response indicates that the server + # successfully processed the request, + # asks that the client reset its document view, and is not returning any content. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/205]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-205-reset-content]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#205]. + # + class HTTPResetContent < HTTPSuccess + # :stopdoc: + HAS_BODY = false + end + + # Response class for <tt>Partial Content</tt> responses (status code 206). + # + # The <tt>Partial Content</tt> response indicates that the server is delivering + # only part of the resource (byte serving) + # due to a Range header in the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-206-partial-content]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#206]. + # + class HTTPPartialContent < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Multi-Status (WebDAV)</tt> responses (status code 207). + # + # The <tt>Multi-Status (WebDAV)</tt> response indicates that the server + # has received the request, + # and that the message body can contain a number of separate response codes. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 4818}[https://www.rfc-editor.org/rfc/rfc4918#section-11.1]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#207]. + # + class HTTPMultiStatus < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Already Reported (WebDAV)</tt> responses (status code 208). + # + # The <tt>Already Reported (WebDAV)</tt> response indicates that the server + # has received the request, + # and that the members of a DAV binding have already been enumerated + # in a preceding part of the (multi-status) response, + # and are not being included again. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 5842}[https://www.rfc-editor.org/rfc/rfc5842.html#section-7.1]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#208]. + # + class HTTPAlreadyReported < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>IM Used</tt> responses (status code 226). + # + # The <tt>IM Used</tt> response indicates that the server has fulfilled a request + # for the resource, and the response is a representation of the result + # of one or more instance-manipulations applied to the current instance. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 3229}[https://www.rfc-editor.org/rfc/rfc3229.html#section-10.4.1]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#226]. + # + class HTTPIMUsed < HTTPSuccess + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Multiple Choices</tt> responses (status code 300). + # + # The <tt>Multiple Choices</tt> response indicates that the server + # offers multiple options for the resource from which the client may choose. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/300]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-300-multiple-choices]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#300]. + # + class HTTPMultipleChoices < HTTPRedirection + # :stopdoc: + HAS_BODY = true + end + HTTPMultipleChoice = HTTPMultipleChoices + + # Response class for <tt>Moved Permanently</tt> responses (status code 301). + # + # The <tt>Moved Permanently</tt> response indicates that links or records + # returning this response should be updated to use the given URL. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-301-moved-permanently]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#301]. + # + class HTTPMovedPermanently < HTTPRedirection + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Found</tt> responses (status code 302). + # + # The <tt>Found</tt> response indicates that the client + # should look at (browse to) another URL. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-302-found]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#302]. + # + class HTTPFound < HTTPRedirection + # :stopdoc: + HAS_BODY = true + end + HTTPMovedTemporarily = HTTPFound + + # Response class for <tt>See Other</tt> responses (status code 303). + # + # The response to the request can be found under another Gem::URI using the GET method. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-303-see-other]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#303]. + # + class HTTPSeeOther < HTTPRedirection + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Not Modified</tt> responses (status code 304). + # + # Indicates that the resource has not been modified since the version + # specified by the request headers. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-304-not-modified]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#304]. + # + class HTTPNotModified < HTTPRedirection + # :stopdoc: + HAS_BODY = false + end + + # Response class for <tt>Use Proxy</tt> responses (status code 305). + # + # The requested resource is available only through a proxy, + # whose address is provided in the response. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-305-use-proxy]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#305]. + # + class HTTPUseProxy < HTTPRedirection + # :stopdoc: + HAS_BODY = false + end + + # Response class for <tt>Temporary Redirect</tt> responses (status code 307). + # + # The request should be repeated with another Gem::URI; + # however, future requests should still use the original Gem::URI. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-307-temporary-redirect]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#307]. + # + class HTTPTemporaryRedirect < HTTPRedirection + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Permanent Redirect</tt> responses (status code 308). + # + # This and all future requests should be directed to the given Gem::URI. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/308]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-308-permanent-redirect]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#308]. + # + class HTTPPermanentRedirect < HTTPRedirection + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Bad Request</tt> responses (status code 400). + # + # The server cannot or will not process the request due to an apparent client error. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#400]. + # + class HTTPBadRequest < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Unauthorized</tt> responses (status code 401). + # + # Authentication is required, but either was not provided or failed. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#401]. + # + class HTTPUnauthorized < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Payment Required</tt> responses (status code 402). + # + # Reserved for future use. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/402]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-402-payment-required]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#402]. + # + class HTTPPaymentRequired < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Forbidden</tt> responses (status code 403). + # + # The request contained valid data and was understood by the server, + # but the server is refusing action. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-403-forbidden]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#403]. + # + class HTTPForbidden < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Not Found</tt> responses (status code 404). + # + # The requested resource could not be found but may be available in the future. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#404]. + # + class HTTPNotFound < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Method Not Allowed</tt> responses (status code 405). + # + # The request method is not supported for the requested resource. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-405-method-not-allowed]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#405]. + # + class HTTPMethodNotAllowed < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Not Acceptable</tt> responses (status code 406). + # + # The requested resource is capable of generating only content + # that not acceptable according to the Accept headers sent in the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-406-not-acceptable]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#406]. + # + class HTTPNotAcceptable < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Proxy Authentication Required</tt> responses (status code 407). + # + # The client must first authenticate itself with the proxy. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/407]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-407-proxy-authentication-re]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#407]. + # + class HTTPProxyAuthenticationRequired < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Request Gem::Timeout</tt> responses (status code 408). + # + # The server timed out waiting for the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-408-request-timeout]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#408]. + # + class HTTPRequestTimeout < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + HTTPRequestTimeOut = HTTPRequestTimeout + + # Response class for <tt>Conflict</tt> responses (status code 409). + # + # The request could not be processed because of conflict in the current state of the resource. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-409-conflict]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#409]. + # + class HTTPConflict < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Gone</tt> responses (status code 410). + # + # The resource requested was previously in use but is no longer available + # and will not be available again. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/410]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-410-gone]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#410]. + # + class HTTPGone < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Length Required</tt> responses (status code 411). + # + # The request did not specify the length of its content, + # which is required by the requested resource. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/411]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-411-length-required]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#411]. + # + class HTTPLengthRequired < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Precondition Failed</tt> responses (status code 412). + # + # The server does not meet one of the preconditions + # specified in the request headers. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/412]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-412-precondition-failed]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#412]. + # + class HTTPPreconditionFailed < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Payload Too Large</tt> responses (status code 413). + # + # The request is larger than the server is willing or able to process. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-413-content-too-large]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#413]. + # + class HTTPPayloadTooLarge < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + HTTPRequestEntityTooLarge = HTTPPayloadTooLarge + + # Response class for <tt>Gem::URI Too Long</tt> responses (status code 414). + # + # The Gem::URI provided was too long for the server to process. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/414]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-414-uri-too-long]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#414]. + # + class HTTPURITooLong < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + HTTPRequestURITooLong = HTTPURITooLong + HTTPRequestURITooLarge = HTTPRequestURITooLong + + # Response class for <tt>Unsupported Media Type</tt> responses (status code 415). + # + # The request entity has a media type which the server or resource does not support. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-415-unsupported-media-type]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#415]. + # + class HTTPUnsupportedMediaType < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Range Not Satisfiable</tt> responses (status code 416). + # + # The request entity has a media type which the server or resource does not support. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-416-range-not-satisfiable]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#416]. + # + class HTTPRangeNotSatisfiable < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + HTTPRequestedRangeNotSatisfiable = HTTPRangeNotSatisfiable + + # Response class for <tt>Expectation Failed</tt> responses (status code 417). + # + # The server cannot meet the requirements of the Expect request-header field. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/417]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-417-expectation-failed]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#417]. + # + class HTTPExpectationFailed < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # 418 I'm a teapot - RFC 2324; a joke RFC + # See https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#418. + + # 420 Enhance Your Calm - Twitter + + # Response class for <tt>Misdirected Request</tt> responses (status code 421). + # + # The request was directed at a server that is not able to produce a response. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-421-misdirected-request]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#421]. + # + class HTTPMisdirectedRequest < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Unprocessable Entity</tt> responses (status code 422). + # + # The request was well-formed but had semantic errors. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-422-unprocessable-content]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#422]. + # + class HTTPUnprocessableEntity < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Locked (WebDAV)</tt> responses (status code 423). + # + # The requested resource is locked. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 4918}[https://www.rfc-editor.org/rfc/rfc4918#section-11.3]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#423]. + # + class HTTPLocked < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Failed Dependency (WebDAV)</tt> responses (status code 424). + # + # The request failed because it depended on another request and that request failed. + # See {424 Failed Dependency (WebDAV)}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#424]. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {RFC 4918}[https://www.rfc-editor.org/rfc/rfc4918#section-11.4]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#424]. + # + class HTTPFailedDependency < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # 425 Too Early + # https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#425. + + # Response class for <tt>Upgrade Required</tt> responses (status code 426). + # + # The client should switch to the protocol given in the Upgrade header field. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/426]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-426-upgrade-required]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#426]. + # + class HTTPUpgradeRequired < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Precondition Required</tt> responses (status code 428). + # + # The origin server requires the request to be conditional. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/428]. + # - {RFC 6585}[https://www.rfc-editor.org/rfc/rfc6585#section-3]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#428]. + # + class HTTPPreconditionRequired < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Too Many Requests</tt> responses (status code 429). + # + # The user has sent too many requests in a given amount of time. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429]. + # - {RFC 6585}[https://www.rfc-editor.org/rfc/rfc6585#section-4]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#429]. + # + class HTTPTooManyRequests < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Request Header Fields Too Large</tt> responses (status code 431). + # + # An individual header field is too large, + # or all the header fields collectively, are too large. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/431]. + # - {RFC 6585}[https://www.rfc-editor.org/rfc/rfc6585#section-5]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#431]. + # + class HTTPRequestHeaderFieldsTooLarge < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Unavailable For Legal Reasons</tt> responses (status code 451). + # + # A server operator has received a legal demand to deny access to a resource or to a set of resources + # that includes the requested resource. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/451]. + # - {RFC 7725}[https://www.rfc-editor.org/rfc/rfc7725.html#section-3]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#451]. + # + class HTTPUnavailableForLegalReasons < HTTPClientError + # :stopdoc: + HAS_BODY = true + end + # 444 No Response - Nginx + # 449 Retry With - Microsoft + # 450 Blocked by Windows Parental Controls - Microsoft + # 499 Client Closed Request - Nginx + + # Response class for <tt>Internal Server Error</tt> responses (status code 500). + # + # An unexpected condition was encountered and no more specific message is suitable. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-500-internal-server-error]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#500]. + # + class HTTPInternalServerError < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Not Implemented</tt> responses (status code 501). + # + # The server either does not recognize the request method, + # or it lacks the ability to fulfil the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-501-not-implemented]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#501]. + # + class HTTPNotImplemented < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Bad Gateway</tt> responses (status code 502). + # + # The server was acting as a gateway or proxy + # and received an invalid response from the upstream server. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-502-bad-gateway]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#502]. + # + class HTTPBadGateway < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Service Unavailable</tt> responses (status code 503). + # + # The server cannot handle the request + # (because it is overloaded or down for maintenance). + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-503-service-unavailable]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#503]. + # + class HTTPServiceUnavailable < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Gateway Gem::Timeout</tt> responses (status code 504). + # + # The server was acting as a gateway or proxy + # and did not receive a timely response from the upstream server. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-504-gateway-timeout]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#504]. + # + class HTTPGatewayTimeout < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + HTTPGatewayTimeOut = HTTPGatewayTimeout + + # Response class for <tt>HTTP Version Not Supported</tt> responses (status code 505). + # + # The server does not support the HTTP version used in the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/505]. + # - {RFC 9110}[https://www.rfc-editor.org/rfc/rfc9110.html#name-505-http-version-not-suppor]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#505]. + # + class HTTPVersionNotSupported < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Variant Also Negotiates</tt> responses (status code 506). + # + # Transparent content negotiation for the request results in a circular reference. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/506]. + # - {RFC 2295}[https://www.rfc-editor.org/rfc/rfc2295#section-8.1]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#506]. + # + class HTTPVariantAlsoNegotiates < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Insufficient Storage (WebDAV)</tt> responses (status code 507). + # + # The server is unable to store the representation needed to complete the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/507]. + # - {RFC 4918}[https://www.rfc-editor.org/rfc/rfc4918#section-11.5]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#507]. + # + class HTTPInsufficientStorage < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Loop Detected (WebDAV)</tt> responses (status code 508). + # + # The server detected an infinite loop while processing the request. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/508]. + # - {RFC 5942}[https://www.rfc-editor.org/rfc/rfc5842.html#section-7.2]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#508]. + # + class HTTPLoopDetected < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + # 509 Bandwidth Limit Exceeded - Apache bw/limited extension + + # Response class for <tt>Not Extended</tt> responses (status code 510). + # + # Further extensions to the request are required for the server to fulfill it. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/510]. + # - {RFC 2774}[https://www.rfc-editor.org/rfc/rfc2774.html#section-7]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#510]. + # + class HTTPNotExtended < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + + # Response class for <tt>Network Authentication Required</tt> responses (status code 511). + # + # The client needs to authenticate to gain network access. + # + # :include: doc/net-http/included_getters.rdoc + # + # References: + # + # - {Mozilla}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/511]. + # - {RFC 6585}[https://www.rfc-editor.org/rfc/rfc6585#section-6]. + # - {Wikipedia}[https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#511]. + # + class HTTPNetworkAuthenticationRequired < HTTPServerError + # :stopdoc: + HAS_BODY = true + end + +end + +class Gem::Net::HTTPResponse + # :stopdoc: + CODE_CLASS_TO_OBJ = { + '1' => Gem::Net::HTTPInformation, + '2' => Gem::Net::HTTPSuccess, + '3' => Gem::Net::HTTPRedirection, + '4' => Gem::Net::HTTPClientError, + '5' => Gem::Net::HTTPServerError + }.freeze + CODE_TO_OBJ = { + '100' => Gem::Net::HTTPContinue, + '101' => Gem::Net::HTTPSwitchProtocol, + '102' => Gem::Net::HTTPProcessing, + '103' => Gem::Net::HTTPEarlyHints, + + '200' => Gem::Net::HTTPOK, + '201' => Gem::Net::HTTPCreated, + '202' => Gem::Net::HTTPAccepted, + '203' => Gem::Net::HTTPNonAuthoritativeInformation, + '204' => Gem::Net::HTTPNoContent, + '205' => Gem::Net::HTTPResetContent, + '206' => Gem::Net::HTTPPartialContent, + '207' => Gem::Net::HTTPMultiStatus, + '208' => Gem::Net::HTTPAlreadyReported, + '226' => Gem::Net::HTTPIMUsed, + + '300' => Gem::Net::HTTPMultipleChoices, + '301' => Gem::Net::HTTPMovedPermanently, + '302' => Gem::Net::HTTPFound, + '303' => Gem::Net::HTTPSeeOther, + '304' => Gem::Net::HTTPNotModified, + '305' => Gem::Net::HTTPUseProxy, + '307' => Gem::Net::HTTPTemporaryRedirect, + '308' => Gem::Net::HTTPPermanentRedirect, + + '400' => Gem::Net::HTTPBadRequest, + '401' => Gem::Net::HTTPUnauthorized, + '402' => Gem::Net::HTTPPaymentRequired, + '403' => Gem::Net::HTTPForbidden, + '404' => Gem::Net::HTTPNotFound, + '405' => Gem::Net::HTTPMethodNotAllowed, + '406' => Gem::Net::HTTPNotAcceptable, + '407' => Gem::Net::HTTPProxyAuthenticationRequired, + '408' => Gem::Net::HTTPRequestTimeout, + '409' => Gem::Net::HTTPConflict, + '410' => Gem::Net::HTTPGone, + '411' => Gem::Net::HTTPLengthRequired, + '412' => Gem::Net::HTTPPreconditionFailed, + '413' => Gem::Net::HTTPPayloadTooLarge, + '414' => Gem::Net::HTTPURITooLong, + '415' => Gem::Net::HTTPUnsupportedMediaType, + '416' => Gem::Net::HTTPRangeNotSatisfiable, + '417' => Gem::Net::HTTPExpectationFailed, + '421' => Gem::Net::HTTPMisdirectedRequest, + '422' => Gem::Net::HTTPUnprocessableEntity, + '423' => Gem::Net::HTTPLocked, + '424' => Gem::Net::HTTPFailedDependency, + '426' => Gem::Net::HTTPUpgradeRequired, + '428' => Gem::Net::HTTPPreconditionRequired, + '429' => Gem::Net::HTTPTooManyRequests, + '431' => Gem::Net::HTTPRequestHeaderFieldsTooLarge, + '451' => Gem::Net::HTTPUnavailableForLegalReasons, + + '500' => Gem::Net::HTTPInternalServerError, + '501' => Gem::Net::HTTPNotImplemented, + '502' => Gem::Net::HTTPBadGateway, + '503' => Gem::Net::HTTPServiceUnavailable, + '504' => Gem::Net::HTTPGatewayTimeout, + '505' => Gem::Net::HTTPVersionNotSupported, + '506' => Gem::Net::HTTPVariantAlsoNegotiates, + '507' => Gem::Net::HTTPInsufficientStorage, + '508' => Gem::Net::HTTPLoopDetected, + '510' => Gem::Net::HTTPNotExtended, + '511' => Gem::Net::HTTPNetworkAuthenticationRequired, + }.freeze +end diff --git a/lib/rubygems/vendor/net-http/lib/net/http/status.rb b/lib/rubygems/vendor/net-http/lib/net/http/status.rb new file mode 100644 index 0000000000..9110b108b8 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/http/status.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative '../http' + +if $0 == __FILE__ + require 'open-uri' + File.foreach(__FILE__) do |line| + puts line + break if line.start_with?('end') + end + puts + puts "Gem::Net::HTTP::STATUS_CODES = {" + url = "https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv" + Gem::URI(url).read.each_line do |line| + code, mes, = line.split(',') + next if ['(Unused)', 'Unassigned', 'Description'].include?(mes) + puts " #{code} => '#{mes}'," + end + puts "} # :nodoc:" +end + +Gem::Net::HTTP::STATUS_CODES = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Content', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Too Early', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended (OBSOLETED)', + 511 => 'Network Authentication Required', +} # :nodoc: diff --git a/lib/rubygems/vendor/net-http/lib/net/https.rb b/lib/rubygems/vendor/net-http/lib/net/https.rb new file mode 100644 index 0000000000..f104c85c81 --- /dev/null +++ b/lib/rubygems/vendor/net-http/lib/net/https.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +=begin + += net/https -- SSL/TLS enhancement for Gem::Net::HTTP. + + This file has been merged with net/http. There is no longer any need to + require_relative 'https' to use HTTPS. + + See Gem::Net::HTTP for details on how to make HTTPS connections. + +== Info + 'OpenSSL for Ruby 2' project + Copyright (C) 2001 GOTOU Yuuzou <gotoyuzo@notwork.org> + All rights reserved. + +== Licence + This program is licensed under the same licence as Ruby. + (See the file 'LICENCE'.) + +=end + +require_relative 'http' +require 'openssl' diff --git a/lib/rubygems/vendor/net-protocol/lib/net/protocol.rb b/lib/rubygems/vendor/net-protocol/lib/net/protocol.rb new file mode 100644 index 0000000000..53d34d8d98 --- /dev/null +++ b/lib/rubygems/vendor/net-protocol/lib/net/protocol.rb @@ -0,0 +1,544 @@ +# frozen_string_literal: true +# +# = net/protocol.rb +# +#-- +# Copyright (c) 1999-2004 Yukihiro Matsumoto +# Copyright (c) 1999-2004 Minero Aoki +# +# written and maintained by Minero Aoki <aamine@loveruby.net> +# +# This program is free software. You can re-distribute and/or +# modify this program under the same terms as Ruby itself, +# Ruby Distribute License or GNU General Public License. +# +# $Id$ +#++ +# +# WARNING: This file is going to remove. +# Do not rely on the implementation written in this file. +# + +require 'socket' +require_relative '../../../timeout/lib/timeout' +require 'io/wait' + +module Gem::Net # :nodoc: + + class Protocol #:nodoc: internal use only + VERSION = "0.2.2" + + private + def Protocol.protocol_param(name, val) + module_eval(<<-End, __FILE__, __LINE__ + 1) + def #{name} + #{val} + end + End + end + + def ssl_socket_connect(s, timeout) + if timeout + while true + raise Gem::Net::OpenTimeout if timeout <= 0 + start = Process.clock_gettime Process::CLOCK_MONOTONIC + # to_io is required because SSLSocket doesn't have wait_readable yet + case s.connect_nonblock(exception: false) + when :wait_readable; s.to_io.wait_readable(timeout) + when :wait_writable; s.to_io.wait_writable(timeout) + else; break + end + timeout -= Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + end + else + s.connect + end + end + end + + + class ProtocolError < StandardError; end + class ProtoSyntaxError < ProtocolError; end + class ProtoFatalError < ProtocolError; end + class ProtoUnknownError < ProtocolError; end + class ProtoServerError < ProtocolError; end + class ProtoAuthError < ProtocolError; end + class ProtoCommandError < ProtocolError; end + class ProtoRetriableError < ProtocolError; end + ProtocRetryError = ProtoRetriableError + + ## + # OpenTimeout, a subclass of Gem::Timeout::Error, is raised if a connection cannot + # be created within the open_timeout. + + class OpenTimeout < Gem::Timeout::Error; end + + ## + # ReadTimeout, a subclass of Gem::Timeout::Error, is raised if a chunk of the + # response cannot be read within the read_timeout. + + class ReadTimeout < Gem::Timeout::Error + def initialize(io = nil) + @io = io + end + attr_reader :io + + def message + msg = super + if @io + msg = "#{msg} with #{@io.inspect}" + end + msg + end + end + + ## + # WriteTimeout, a subclass of Gem::Timeout::Error, is raised if a chunk of the + # response cannot be written within the write_timeout. Not raised on Windows. + + class WriteTimeout < Gem::Timeout::Error + def initialize(io = nil) + @io = io + end + attr_reader :io + + def message + msg = super + if @io + msg = "#{msg} with #{@io.inspect}" + end + msg + end + end + + + class BufferedIO #:nodoc: internal use only + def initialize(io, read_timeout: 60, write_timeout: 60, continue_timeout: nil, debug_output: nil) + @io = io + @read_timeout = read_timeout + @write_timeout = write_timeout + @continue_timeout = continue_timeout + @debug_output = debug_output + @rbuf = ''.b + @rbuf_empty = true + @rbuf_offset = 0 + end + + attr_reader :io + attr_accessor :read_timeout + attr_accessor :write_timeout + attr_accessor :continue_timeout + attr_accessor :debug_output + + def inspect + "#<#{self.class} io=#{@io}>" + end + + def eof? + @io.eof? + end + + def closed? + @io.closed? + end + + def close + @io.close + end + + # + # Read + # + + public + + def read(len, dest = ''.b, ignore_eof = false) + LOG "reading #{len} bytes..." + read_bytes = 0 + begin + while read_bytes + rbuf_size < len + if s = rbuf_consume_all + read_bytes += s.bytesize + dest << s + end + rbuf_fill + end + s = rbuf_consume(len - read_bytes) + read_bytes += s.bytesize + dest << s + rescue EOFError + raise unless ignore_eof + end + LOG "read #{read_bytes} bytes" + dest + end + + def read_all(dest = ''.b) + LOG 'reading all...' + read_bytes = 0 + begin + while true + if s = rbuf_consume_all + read_bytes += s.bytesize + dest << s + end + rbuf_fill + end + rescue EOFError + ; + end + LOG "read #{read_bytes} bytes" + dest + end + + def readuntil(terminator, ignore_eof = false) + offset = @rbuf_offset + begin + until idx = @rbuf.index(terminator, offset) + offset = @rbuf.bytesize + rbuf_fill + end + return rbuf_consume(idx + terminator.bytesize - @rbuf_offset) + rescue EOFError + raise unless ignore_eof + return rbuf_consume + end + end + + def readline + readuntil("\n").chop + end + + private + + BUFSIZE = 1024 * 16 + + def rbuf_fill + tmp = @rbuf_empty ? @rbuf : nil + case rv = @io.read_nonblock(BUFSIZE, tmp, exception: false) + when String + @rbuf_empty = false + if rv.equal?(tmp) + @rbuf_offset = 0 + else + @rbuf << rv + rv.clear + end + return + when :wait_readable + (io = @io.to_io).wait_readable(@read_timeout) or raise Gem::Net::ReadTimeout.new(io) + # continue looping + when :wait_writable + # OpenSSL::Buffering#read_nonblock may fail with IO::WaitWritable. + # http://www.openssl.org/support/faq.html#PROG10 + (io = @io.to_io).wait_writable(@read_timeout) or raise Gem::Net::ReadTimeout.new(io) + # continue looping + when nil + raise EOFError, 'end of file reached' + end while true + end + + def rbuf_flush + if @rbuf_empty + @rbuf.clear + @rbuf_offset = 0 + end + nil + end + + def rbuf_size + @rbuf.bytesize - @rbuf_offset + end + + def rbuf_consume_all + rbuf_consume if rbuf_size > 0 + end + + def rbuf_consume(len = nil) + if @rbuf_offset == 0 && (len.nil? || len == @rbuf.bytesize) + s = @rbuf + @rbuf = ''.b + @rbuf_offset = 0 + @rbuf_empty = true + elsif len.nil? + s = @rbuf.byteslice(@rbuf_offset..-1) + @rbuf = ''.b + @rbuf_offset = 0 + @rbuf_empty = true + else + s = @rbuf.byteslice(@rbuf_offset, len) + @rbuf_offset += len + @rbuf_empty = @rbuf_offset == @rbuf.bytesize + rbuf_flush + end + + @debug_output << %Q[-> #{s.dump}\n] if @debug_output + s + end + + # + # Write + # + + public + + def write(*strs) + writing { + write0(*strs) + } + end + + alias << write + + def writeline(str) + writing { + write0 str + "\r\n" + } + end + + private + + def writing + @written_bytes = 0 + @debug_output << '<- ' if @debug_output + yield + @debug_output << "\n" if @debug_output + bytes = @written_bytes + @written_bytes = nil + bytes + end + + def write0(*strs) + @debug_output << strs.map(&:dump).join if @debug_output + orig_written_bytes = @written_bytes + strs.each_with_index do |str, i| + need_retry = true + case len = @io.write_nonblock(str, exception: false) + when Integer + @written_bytes += len + len -= str.bytesize + if len == 0 + if strs.size == i+1 + return @written_bytes - orig_written_bytes + else + need_retry = false + # next string + end + elsif len < 0 + str = str.byteslice(len, -len) + else # len > 0 + need_retry = false + # next string + end + # continue looping + when :wait_writable + (io = @io.to_io).wait_writable(@write_timeout) or raise Gem::Net::WriteTimeout.new(io) + # continue looping + end while need_retry + end + end + + # + # Logging + # + + private + + def LOG_off + @save_debug_out = @debug_output + @debug_output = nil + end + + def LOG_on + @debug_output = @save_debug_out + end + + def LOG(msg) + return unless @debug_output + @debug_output << msg + "\n" + end + end + + + class InternetMessageIO < BufferedIO #:nodoc: internal use only + def initialize(*, **) + super + @wbuf = nil + end + + # + # Read + # + + def each_message_chunk + LOG 'reading message...' + LOG_off() + read_bytes = 0 + while (line = readuntil("\r\n")) != ".\r\n" + read_bytes += line.size + yield line.delete_prefix('.') + end + LOG_on() + LOG "read message (#{read_bytes} bytes)" + end + + # *library private* (cannot handle 'break') + def each_list_item + while (str = readuntil("\r\n")) != ".\r\n" + yield str.chop + end + end + + def write_message_0(src) + prev = @written_bytes + each_crlf_line(src) do |line| + write0 dot_stuff(line) + end + @written_bytes - prev + end + + # + # Write + # + + def write_message(src) + LOG "writing message from #{src.class}" + LOG_off() + len = writing { + using_each_crlf_line { + write_message_0 src + } + } + LOG_on() + LOG "wrote #{len} bytes" + len + end + + def write_message_by_block(&block) + LOG 'writing message from block' + LOG_off() + len = writing { + using_each_crlf_line { + begin + block.call(WriteAdapter.new(self.method(:write_message_0))) + rescue LocalJumpError + # allow `break' from writer block + end + } + } + LOG_on() + LOG "wrote #{len} bytes" + len + end + + private + + def dot_stuff(s) + s.sub(/\A\./, '..') + end + + def using_each_crlf_line + @wbuf = ''.b + yield + if not @wbuf.empty? # unterminated last line + write0 dot_stuff(@wbuf.chomp) + "\r\n" + elsif @written_bytes == 0 # empty src + write0 "\r\n" + end + write0 ".\r\n" + @wbuf = nil + end + + def each_crlf_line(src) + buffer_filling(@wbuf, src) do + while line = @wbuf.slice!(/\A[^\r\n]*(?:\n|\r(?:\n|(?!\z)))/) + yield line.chomp("\n") + "\r\n" + end + end + end + + def buffer_filling(buf, src) + case src + when String # for speeding up. + 0.step(src.size - 1, 1024) do |i| + buf << src[i, 1024] + yield + end + when File # for speeding up. + while s = src.read(1024) + buf << s + yield + end + else # generic reader + src.each do |str| + buf << str + yield if buf.size > 1024 + end + yield unless buf.empty? + end + end + end + + + # + # The writer adapter class + # + class WriteAdapter + def initialize(writer) + @writer = writer + end + + def inspect + "#<#{self.class} writer=#{@writer.inspect}>" + end + + def write(str) + @writer.call(str) + end + + alias print write + + def <<(str) + write str + self + end + + def puts(str = '') + write str.chomp("\n") + "\n" + end + + def printf(*args) + write sprintf(*args) + end + end + + + class ReadAdapter #:nodoc: internal use only + def initialize(block) + @block = block + end + + def inspect + "#<#{self.class}>" + end + + def <<(str) + call_block(str, &@block) if @block + end + + private + + # This method is needed because @block must be called by yield, + # not Proc#call. You can see difference when using `break' in + # the block. + def call_block(str) + yield str + end + end + + + module NetPrivate #:nodoc: obsolete + Socket = ::Gem::Net::InternetMessageIO + end + +end # module Gem::Net diff --git a/lib/rubygems/vendor/optparse/lib/optionparser.rb b/lib/rubygems/vendor/optparse/lib/optionparser.rb new file mode 100644 index 0000000000..4b9b40d82a --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optionparser.rb @@ -0,0 +1,2 @@ +# frozen_string_literal: false +require_relative 'optparse' diff --git a/lib/rubygems/vendor/optparse/lib/optparse.rb b/lib/rubygems/vendor/optparse/lib/optparse.rb new file mode 100644 index 0000000000..d39d9dd4e0 --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse.rb @@ -0,0 +1,2467 @@ +# frozen_string_literal: true +# +# optparse.rb - command-line option analysis with the Gem::OptionParser class. +# +# Author:: Nobu Nakada +# Documentation:: Nobu Nakada and Gavin Sinclair. +# +# See Gem::OptionParser for documentation. +# +require 'set' unless defined?(Set) + +#-- +# == Developer Documentation (not for RDoc output) +# +# === Class tree +# +# - Gem::OptionParser:: front end +# - Gem::OptionParser::Switch:: each switches +# - Gem::OptionParser::List:: options list +# - Gem::OptionParser::ParseError:: errors on parsing +# - Gem::OptionParser::AmbiguousOption +# - Gem::OptionParser::NeedlessArgument +# - Gem::OptionParser::MissingArgument +# - Gem::OptionParser::InvalidOption +# - Gem::OptionParser::InvalidArgument +# - Gem::OptionParser::AmbiguousArgument +# +# === Object relationship diagram +# +# +--------------+ +# | Gem::OptionParser |<>-----+ +# +--------------+ | +--------+ +# | ,-| Switch | +# on_head -------->+---------------+ / +--------+ +# accept/reject -->| List |<|>- +# | |<|>- +----------+ +# on ------------->+---------------+ `-| argument | +# : : | class | +# +---------------+ |==========| +# on_tail -------->| | |pattern | +# +---------------+ |----------| +# Gem::OptionParser.accept ->| DefaultList | |converter | +# reject |(shared between| +----------+ +# | all instances)| +# +---------------+ +# +#++ +# +# == Gem::OptionParser +# +# === New to +Gem::OptionParser+? +# +# See the {Tutorial}[optparse/tutorial.rdoc]. +# +# === Introduction +# +# Gem::OptionParser is a class for command-line option analysis. It is much more +# advanced, yet also easier to use, than GetoptLong, and is a more Ruby-oriented +# solution. +# +# === Features +# +# 1. The argument specification and the code to handle it are written in the +# same place. +# 2. It can output an option summary; you don't need to maintain this string +# separately. +# 3. Optional and mandatory arguments are specified very gracefully. +# 4. Arguments can be automatically converted to a specified class. +# 5. Arguments can be restricted to a certain set. +# +# All of these features are demonstrated in the examples below. See +# #make_switch for full documentation. +# +# === Minimal example +# +# require 'rubygems/vendor/optparse/lib/optparse' +# +# options = {} +# Gem::OptionParser.new do |parser| +# parser.banner = "Usage: example.rb [options]" +# +# parser.on("-v", "--[no-]verbose", "Run verbosely") do |v| +# options[:verbose] = v +# end +# end.parse! +# +# p options +# p ARGV +# +# === Generating Help +# +# Gem::OptionParser can be used to automatically generate help for the commands you +# write: +# +# require 'rubygems/vendor/optparse/lib/optparse' +# +# Options = Struct.new(:name) +# +# class Parser +# def self.parse(options) +# args = Options.new("world") +# +# opt_parser = Gem::OptionParser.new do |parser| +# parser.banner = "Usage: example.rb [options]" +# +# parser.on("-nNAME", "--name=NAME", "Name to say hello to") do |n| +# args.name = n +# end +# +# parser.on("-h", "--help", "Prints this help") do +# puts parser +# exit +# end +# end +# +# opt_parser.parse!(options) +# return args +# end +# end +# options = Parser.parse %w[--help] +# +# #=> +# # Usage: example.rb [options] +# # -n, --name=NAME Name to say hello to +# # -h, --help Prints this help +# +# === Required Arguments +# +# For options that require an argument, option specification strings may include an +# option name in all caps. If an option is used without the required argument, +# an exception will be raised. +# +# require 'rubygems/vendor/optparse/lib/optparse' +# +# options = {} +# Gem::OptionParser.new do |parser| +# parser.on("-r", "--require LIBRARY", +# "Require the LIBRARY before executing your script") do |lib| +# puts "You required #{lib}!" +# end +# end.parse! +# +# Used: +# +# $ ruby optparse-test.rb -r +# optparse-test.rb:9:in '<main>': missing argument: -r (Gem::OptionParser::MissingArgument) +# $ ruby optparse-test.rb -r my-library +# You required my-library! +# +# === Type Coercion +# +# Gem::OptionParser supports the ability to coerce command line arguments +# into objects for us. +# +# Gem::OptionParser comes with a few ready-to-use kinds of type +# coercion. They are: +# +# - Date -- Anything accepted by +Date.parse+ (need to require +optparse/date+) +# - DateTime -- Anything accepted by +DateTime.parse+ (need to require +optparse/date+) +# - Time -- Anything accepted by +Time.httpdate+ or +Time.parse+ (need to require +optparse/time+) +# - URI -- Anything accepted by +Gem::URI.parse+ (need to require +optparse/uri+) +# - Shellwords -- Anything accepted by +Shellwords.shellwords+ (need to require +optparse/shellwords+) +# - String -- Any non-empty string +# - Integer -- Any integer. Will convert octal. (e.g. 124, -3, 040) +# - Float -- Any float. (e.g. 10, 3.14, -100E+13) +# - Numeric -- Any integer, float, or rational (1, 3.4, 1/3) +# - DecimalInteger -- Like +Integer+, but no octal format. +# - OctalInteger -- Like +Integer+, but no decimal format. +# - DecimalNumeric -- Decimal integer or float. +# - TrueClass -- Accepts '+, yes, true, -, no, false' and +# defaults as +true+ +# - FalseClass -- Same as +TrueClass+, but defaults to +false+ +# - Array -- Strings separated by ',' (e.g. 1,2,3) +# - Regexp -- Regular expressions. Also includes options. +# +# We can also add our own coercions, which we will cover below. +# +# ==== Using Built-in Conversions +# +# As an example, the built-in +Time+ conversion is used. The other built-in +# conversions behave in the same way. +# Gem::OptionParser will attempt to parse the argument +# as a +Time+. If it succeeds, that time will be passed to the +# handler block. Otherwise, an exception will be raised. +# +# require 'rubygems/vendor/optparse/lib/optparse' +# require 'rubygems/vendor/optparse/lib/optparse/time' +# Gem::OptionParser.new do |parser| +# parser.on("-t", "--time [TIME]", Time, "Begin execution at given time") do |time| +# p time +# end +# end.parse! +# +# Used: +# +# $ ruby optparse-test.rb -t nonsense +# ... invalid argument: -t nonsense (Gem::OptionParser::InvalidArgument) +# $ ruby optparse-test.rb -t 10-11-12 +# 2010-11-12 00:00:00 -0500 +# $ ruby optparse-test.rb -t 9:30 +# 2014-08-13 09:30:00 -0400 +# +# ==== Creating Custom Conversions +# +# The +accept+ method on Gem::OptionParser may be used to create converters. +# It specifies which conversion block to call whenever a class is specified. +# The example below uses it to fetch a +User+ object before the +on+ handler receives it. +# +# require 'rubygems/vendor/optparse/lib/optparse' +# +# User = Struct.new(:id, :name) +# +# def find_user id +# not_found = ->{ raise "No User Found for id #{id}" } +# [ User.new(1, "Sam"), +# User.new(2, "Gandalf") ].find(not_found) do |u| +# u.id == id +# end +# end +# +# op = Gem::OptionParser.new +# op.accept(User) do |user_id| +# find_user user_id.to_i +# end +# +# op.on("--user ID", User) do |user| +# puts user +# end +# +# op.parse! +# +# Used: +# +# $ ruby optparse-test.rb --user 1 +# #<struct User id=1, name="Sam"> +# $ ruby optparse-test.rb --user 2 +# #<struct User id=2, name="Gandalf"> +# $ ruby optparse-test.rb --user 3 +# optparse-test.rb:15:in 'block in find_user': No User Found for id 3 (RuntimeError) +# +# === Store options to a Hash +# +# The +into+ option of +order+, +parse+ and so on methods stores command line options into a Hash. +# +# require 'rubygems/vendor/optparse/lib/optparse' +# +# options = {} +# Gem::OptionParser.new do |parser| +# parser.on('-a') +# parser.on('-b NUM', Integer) +# parser.on('-v', '--verbose') +# end.parse!(into: options) +# +# p options +# +# Used: +# +# $ ruby optparse-test.rb -a +# {:a=>true} +# $ ruby optparse-test.rb -a -v +# {:a=>true, :verbose=>true} +# $ ruby optparse-test.rb -a -b 100 +# {:a=>true, :b=>100} +# +# === Complete example +# +# The following example is a complete Ruby program. You can run it and see the +# effect of specifying various options. This is probably the best way to learn +# the features of +optparse+. +# +# require 'rubygems/vendor/optparse/lib/optparse' +# require 'rubygems/vendor/optparse/lib/optparse/time' +# require 'ostruct' +# require 'pp' +# +# class OptparseExample +# Version = '1.0.0' +# +# CODES = %w[iso-2022-jp shift_jis euc-jp utf8 binary] +# CODE_ALIASES = { "jis" => "iso-2022-jp", "sjis" => "shift_jis" } +# +# class ScriptOptions +# attr_accessor :library, :inplace, :encoding, :transfer_type, +# :verbose, :extension, :delay, :time, :record_separator, +# :list +# +# def initialize +# self.library = [] +# self.inplace = false +# self.encoding = "utf8" +# self.transfer_type = :auto +# self.verbose = false +# end +# +# def define_options(parser) +# parser.banner = "Usage: example.rb [options]" +# parser.separator "" +# parser.separator "Specific options:" +# +# # add additional options +# perform_inplace_option(parser) +# delay_execution_option(parser) +# execute_at_time_option(parser) +# specify_record_separator_option(parser) +# list_example_option(parser) +# specify_encoding_option(parser) +# optional_option_argument_with_keyword_completion_option(parser) +# boolean_verbose_option(parser) +# +# parser.separator "" +# parser.separator "Common options:" +# # No argument, shows at tail. This will print an options summary. +# # Try it and see! +# parser.on_tail("-h", "--help", "Show this message") do +# puts parser +# exit +# end +# # Another typical switch to print the version. +# parser.on_tail("--version", "Show version") do +# puts Version +# exit +# end +# end +# +# def perform_inplace_option(parser) +# # Specifies an optional option argument +# parser.on("-i", "--inplace [EXTENSION]", +# "Edit ARGV files in place", +# "(make backup if EXTENSION supplied)") do |ext| +# self.inplace = true +# self.extension = ext || '' +# self.extension.sub!(/\A\.?(?=.)/, ".") # Ensure extension begins with dot. +# end +# end +# +# def delay_execution_option(parser) +# # Cast 'delay' argument to a Float. +# parser.on("--delay N", Float, "Delay N seconds before executing") do |n| +# self.delay = n +# end +# end +# +# def execute_at_time_option(parser) +# # Cast 'time' argument to a Time object. +# parser.on("-t", "--time [TIME]", Time, "Begin execution at given time") do |time| +# self.time = time +# end +# end +# +# def specify_record_separator_option(parser) +# # Cast to octal integer. +# parser.on("-F", "--irs [OCTAL]", Gem::OptionParser::OctalInteger, +# "Specify record separator (default \\0)") do |rs| +# self.record_separator = rs +# end +# end +# +# def list_example_option(parser) +# # List of arguments. +# parser.on("--list x,y,z", Array, "Example 'list' of arguments") do |list| +# self.list = list +# end +# end +# +# def specify_encoding_option(parser) +# # Keyword completion. We are specifying a specific set of arguments (CODES +# # and CODE_ALIASES - notice the latter is a Hash), and the user may provide +# # the shortest unambiguous text. +# code_list = (CODE_ALIASES.keys + CODES).join(', ') +# parser.on("--code CODE", CODES, CODE_ALIASES, "Select encoding", +# "(#{code_list})") do |encoding| +# self.encoding = encoding +# end +# end +# +# def optional_option_argument_with_keyword_completion_option(parser) +# # Optional '--type' option argument with keyword completion. +# parser.on("--type [TYPE]", [:text, :binary, :auto], +# "Select transfer type (text, binary, auto)") do |t| +# self.transfer_type = t +# end +# end +# +# def boolean_verbose_option(parser) +# # Boolean switch. +# parser.on("-v", "--[no-]verbose", "Run verbosely") do |v| +# self.verbose = v +# end +# end +# end +# +# # +# # Return a structure describing the options. +# # +# def parse(args) +# # The options specified on the command line will be collected in +# # *options*. +# +# @options = ScriptOptions.new +# @args = Gem::OptionParser.new do |parser| +# @options.define_options(parser) +# parser.parse!(args) +# end +# @options +# end +# +# attr_reader :parser, :options +# end # class OptparseExample +# +# example = OptparseExample.new +# options = example.parse(ARGV) +# pp options # example.options +# pp ARGV +# +# === Shell Completion +# +# For modern shells (e.g. bash, zsh, etc.), you can use shell +# completion for command line options. +# +# === Further documentation +# +# The above examples, along with the accompanying +# {Tutorial}[optparse/tutorial.rdoc], +# should be enough to learn how to use this class. +# If you have any questions, file a ticket at http://bugs.ruby-lang.org. +# +class Gem::OptionParser + # The version string + VERSION = "0.8.0" + Version = VERSION # for compatibility + + # :stopdoc: + NoArgument = [NO_ARGUMENT = :NONE, nil].freeze + RequiredArgument = [REQUIRED_ARGUMENT = :REQUIRED, true].freeze + OptionalArgument = [OPTIONAL_ARGUMENT = :OPTIONAL, false].freeze + # :startdoc: + + # + # Keyword completion module. This allows partial arguments to be specified + # and resolved against a list of acceptable values. + # + module Completion + # :nodoc: + + def self.regexp(key, icase) + Regexp.new('\A' + Regexp.quote(key).gsub(/\w+\b/, '\&\w*'), icase) + end + + def self.candidate(key, icase = false, pat = nil, &block) + pat ||= Completion.regexp(key, icase) + candidates = [] + block.call do |k, *v| + (if Regexp === k + kn = "" + k === key + else + kn = defined?(k.id2name) ? k.id2name : k + pat === kn + end) or next + v << k if v.empty? + candidates << [k, v, kn] + end + candidates + end + + def self.completable?(key) + String.try_convert(key) or defined?(key.id2name) + end + + def candidate(key, icase = false, pat = nil, &_) + Completion.candidate(key, icase, pat, &method(:each)) + end + + public + def complete(key, icase = false, pat = nil) + candidates = candidate(key, icase, pat, &method(:each)).sort_by {|k, v, kn| kn.size} + if candidates.size == 1 + canon, sw, * = candidates[0] + elsif candidates.size > 1 + canon, sw, cn = candidates.shift + candidates.each do |k, v, kn| + next if sw == v + if String === cn and String === kn + if cn.rindex(kn, 0) + canon, sw, cn = k, v, kn + next + elsif kn.rindex(cn, 0) + next + end + end + throw :ambiguous, key + end + end + if canon + block_given? or return key, *sw + yield(key, *sw) + end + end + + def convert(opt = nil, val = nil, *) + val + end + end + + # + # Map from option/keyword string to object with completion. + # + class OptionMap < Hash + include Completion + end + + # + # Individual switch class. Not important to the user. + # + # Defined within Switch are several Switch-derived classes: NoArgument, + # RequiredArgument, etc. + # + class Switch + # :nodoc: + + attr_reader :pattern, :conv, :short, :long, :arg, :desc, :block + + # + # Guesses argument style from +arg+. Returns corresponding + # Gem::OptionParser::Switch class (OptionalArgument, etc.). + # + def self.guess(arg) + case arg + when "" + t = self + when /\A=?\[/ + t = Switch::OptionalArgument + when /\A\s+\[/ + t = Switch::PlacedArgument + else + t = Switch::RequiredArgument + end + self >= t or incompatible_argument_styles(arg, t) + t + end + + def self.incompatible_argument_styles(arg, t) + raise(ArgumentError, "#{arg}: incompatible argument styles\n #{self}, #{t}", + ParseError.filter_backtrace(caller(2))) + end + + def self.pattern + NilClass + end + + def initialize(pattern = nil, conv = nil, + short = nil, long = nil, arg = nil, + desc = ([] if short or long), block = nil, values = nil, &_block) + raise if Array === pattern + block ||= _block + @pattern, @conv, @short, @long, @arg, @desc, @block, @values = + pattern, conv, short, long, arg, desc, block, values + end + + # + # Parses +arg+ and returns rest of +arg+ and matched portion to the + # argument pattern. Yields when the pattern doesn't match substring. + # + def parse_arg(arg) # :nodoc: + pattern or return nil, [arg] + unless m = pattern.match(arg) + yield(InvalidArgument, arg) + return arg, [] + end + if String === m + m = [s = m] + else + m = m.to_a + s = m[0] + return nil, m unless String === s + end + raise InvalidArgument, arg unless arg.rindex(s, 0) + return nil, m if s.length == arg.length + yield(InvalidArgument, arg) # didn't match whole arg + return arg[s.length..-1], m + end + private :parse_arg + + # + # Parses argument, converts and returns +arg+, +block+ and result of + # conversion. Yields at semi-error condition instead of raising an + # exception. + # + def conv_arg(arg, val = []) # :nodoc: + v, = *val + if conv + val = conv.call(*val) + else + val = proc {|v| v}.call(*val) + end + if @values + @values.include?(val) or raise InvalidArgument, v + end + return arg, block, val + end + private :conv_arg + + # + # Produces the summary text. Each line of the summary is yielded to the + # block (without newline). + # + # +sdone+:: Already summarized short style options keyed hash. + # +ldone+:: Already summarized long style options keyed hash. + # +width+:: Width of left side (option part). In other words, the right + # side (description part) starts after +width+ columns. + # +max+:: Maximum width of left side -> the options are filled within + # +max+ columns. + # +indent+:: Prefix string indents all summarized lines. + # + def summarize(sdone = {}, ldone = {}, width = 1, max = width - 1, indent = "") + sopts, lopts = [], [], nil + @short.each {|s| sdone.fetch(s) {sopts << s}; sdone[s] = true} if @short + @long.each {|s| ldone.fetch(s) {lopts << s}; ldone[s] = true} if @long + return if sopts.empty? and lopts.empty? # completely hidden + + left = [sopts.join(', ')] + right = desc.dup + + while s = lopts.shift + l = left[-1].length + s.length + l += arg.length if left.size == 1 && arg + l < max or sopts.empty? or left << +'' + left[-1] << (left[-1].empty? ? ' ' * 4 : ', ') << s + end + + if arg + left[0] << (left[1] ? arg.sub(/\A(\[?)=/, '\1') + ',' : arg) + end + mlen = left.collect {|ss| ss.length}.max.to_i + while mlen > width and l = left.shift + mlen = left.collect {|ss| ss.length}.max.to_i if l.length == mlen + if l.length < width and (r = right[0]) and !r.empty? + l = l.to_s.ljust(width) + ' ' + r + right.shift + end + yield(indent + l) + end + + while begin l = left.shift; r = right.shift; l or r end + l = l.to_s.ljust(width) + ' ' + r if r and !r.empty? + yield(indent + l) + end + + self + end + + def add_banner(to) # :nodoc: + unless @short or @long + s = desc.join + to << " [" + s + "]..." unless s.empty? + end + to + end + + def match_nonswitch?(str) # :nodoc: + @pattern =~ str unless @short or @long + end + + # + # Main name of the switch. + # + def switch_name + (long.first || short.first).sub(/\A-+(?:\[no-\])?/, '') + end + + def compsys(sdone, ldone) # :nodoc: + sopts, lopts = [], [] + @short.each {|s| sdone.fetch(s) {sopts << s}; sdone[s] = true} if @short + @long.each {|s| ldone.fetch(s) {lopts << s}; ldone[s] = true} if @long + return if sopts.empty? and lopts.empty? # completely hidden + + (sopts+lopts).each do |opt| + # "(-x -c -r)-l[left justify]" + if /\A--\[no-\](.+)$/ =~ opt + o = $1 + yield("--#{o}", desc.join("")) + yield("--no-#{o}", desc.join("")) + else + yield("#{opt}", desc.join("")) + end + end + end + + def pretty_print_contents(q) # :nodoc: + if @block + q.text ":" + @block.source_location.join(":") + ":" + first = false + else + first = true + end + [@short, @long].each do |list| + list.each do |opt| + if first + q.text ":" + first = false + end + q.breakable + q.text opt + end + end + end + + def pretty_print(q) # :nodoc: + q.object_group(self) {pretty_print_contents(q)} + end + + def omitted_argument(val) # :nodoc: + val.pop if val.size == 3 and val.last.nil? + val + end + + # + # Switch that takes no arguments. + # + class NoArgument < self + + # + # Raises an exception if any arguments given. + # + def parse(arg, argv) + yield(NeedlessArgument, arg) if arg + conv_arg(arg) + end + + def self.incompatible_argument_styles(*) # :nodoc: + end + + def self.pattern # :nodoc: + Object + end + + def pretty_head # :nodoc: + "NoArgument" + end + end + + # + # Switch that takes an argument. + # + class RequiredArgument < self + + # + # Raises an exception if argument is not present. + # + def parse(arg, argv, &_) + unless arg + raise MissingArgument if argv.empty? + arg = argv.shift + end + conv_arg(*parse_arg(arg, &method(:raise))) + end + + def pretty_head # :nodoc: + "Required" + end + end + + # + # Switch that can omit argument. + # + class OptionalArgument < self + + # + # Parses argument if given, or uses default value. + # + def parse(arg, argv, &error) + if arg + conv_arg(*parse_arg(arg, &error)) + else + omitted_argument conv_arg(arg) + end + end + + def pretty_head # :nodoc: + "Optional" + end + end + + # + # Switch that takes an argument, which does not begin with '-' or is '-'. + # + class PlacedArgument < self + + # + # Returns nil if argument is not present or begins with '-' and is not '-'. + # + def parse(arg, argv, &error) + if !(val = arg) and (argv.empty? or /\A-./ =~ (val = argv[0])) + return nil, block + end + opt = (val = parse_arg(val, &error))[1] + val = conv_arg(*val) + if opt and !arg + argv.shift + else + omitted_argument val + val[0] = nil + end + val + end + + def pretty_head # :nodoc: + "Placed" + end + end + end + + # + # Simple option list providing mapping from short and/or long option + # string to Gem::OptionParser::Switch and mapping from acceptable argument to + # matching pattern and converter pair. Also provides summary feature. + # + class List + # :nodoc: + + # Map from acceptable argument types to pattern and converter pairs. + attr_reader :atype + + # Map from short style option switches to actual switch objects. + attr_reader :short + + # Map from long style option switches to actual switch objects. + attr_reader :long + + # List of all switches and summary string. + attr_reader :list + + # + # Just initializes all instance variables. + # + def initialize + @atype = {} + @short = OptionMap.new + @long = OptionMap.new + @list = [] + end + + def pretty_print(q) # :nodoc: + q.group(1, "(", ")") do + @list.each do |sw| + next unless Switch === sw + q.group(1, "(" + sw.pretty_head, ")") do + sw.pretty_print_contents(q) + end + end + end + end + + # + # See Gem::OptionParser.accept. + # + def accept(t, pat = /.*/m, &block) + if pat + pat.respond_to?(:match) or + raise TypeError, "has no 'match'", ParseError.filter_backtrace(caller(2)) + else + pat = t if t.respond_to?(:match) + end + unless block + block = pat.method(:convert).to_proc if pat.respond_to?(:convert) + end + @atype[t] = [pat, block] + end + + # + # See Gem::OptionParser.reject. + # + def reject(t) + @atype.delete(t) + end + + # + # Adds +sw+ according to +sopts+, +lopts+ and +nlopts+. + # + # +sw+:: Gem::OptionParser::Switch instance to be added. + # +sopts+:: Short style option list. + # +lopts+:: Long style option list. + # +nlopts+:: Negated long style options list. + # + def update(sw, sopts, lopts, nsw = nil, nlopts = nil) # :nodoc: + sopts.each {|o| @short[o] = sw} if sopts + lopts.each {|o| @long[o] = sw} if lopts + nlopts.each {|o| @long[o] = nsw} if nsw and nlopts + used = @short.invert.update(@long.invert) + @list.delete_if {|o| Switch === o and !used[o]} + end + private :update + + # + # Inserts +switch+ at the head of the list, and associates short, long + # and negated long options. Arguments are: + # + # +switch+:: Gem::OptionParser::Switch instance to be inserted. + # +short_opts+:: List of short style options. + # +long_opts+:: List of long style options. + # +nolong_opts+:: List of long style options with "no-" prefix. + # + # prepend(switch, short_opts, long_opts, nolong_opts) + # + def prepend(*args) + update(*args) + @list.unshift(args[0]) + end + + # + # Appends +switch+ at the tail of the list, and associates short, long + # and negated long options. Arguments are: + # + # +switch+:: Gem::OptionParser::Switch instance to be inserted. + # +short_opts+:: List of short style options. + # +long_opts+:: List of long style options. + # +nolong_opts+:: List of long style options with "no-" prefix. + # + # append(switch, short_opts, long_opts, nolong_opts) + # + def append(*args) + update(*args) + @list.push(args[0]) + end + + # + # Searches +key+ in +id+ list. The result is returned or yielded if a + # block is given. If it isn't found, nil is returned. + # + def search(id, key) + if list = __send__(id) + val = list.fetch(key) {return nil} + block_given? ? yield(val) : val + end + end + + # + # Searches list +id+ for +opt+ and the optional patterns for completion + # +pat+. If +icase+ is true, the search is case insensitive. The result + # is returned or yielded if a block is given. If it isn't found, nil is + # returned. + # + def complete(id, opt, icase = false, *pat, &block) + __send__(id).complete(opt, icase, *pat, &block) + end + + def get_candidates(id) + yield __send__(id).keys + end + + # + # Iterates over each option, passing the option to the +block+. + # + def each_option(&block) + list.each(&block) + end + + # + # Creates the summary table, passing each line to the +block+ (without + # newline). The arguments +args+ are passed along to the summarize + # method which is called on every option. + # + def summarize(*args, &block) + sum = [] + list.reverse_each do |opt| + if opt.respond_to?(:summarize) # perhaps Gem::OptionParser::Switch + s = [] + opt.summarize(*args) {|l| s << l} + sum.concat(s.reverse) + elsif !opt or opt.empty? + sum << "" + elsif opt.respond_to?(:each_line) + sum.concat([*opt.each_line].reverse) + else + sum.concat([*opt.each].reverse) + end + end + sum.reverse_each(&block) + end + + def add_banner(to) # :nodoc: + list.each do |opt| + if opt.respond_to?(:add_banner) + opt.add_banner(to) + end + end + to + end + + def compsys(*args, &block) # :nodoc: + list.each do |opt| + if opt.respond_to?(:compsys) + opt.compsys(*args, &block) + end + end + end + end + + # + # Hash with completion search feature. See Gem::OptionParser::Completion. + # + class CompletingHash < Hash + include Completion + + # + # Completion for hash key. + # + def match(key) + *values = fetch(key) { + raise AmbiguousArgument, catch(:ambiguous) {return complete(key)} + } + return key, *values + end + end + + # :stopdoc: + + # + # Enumeration of acceptable argument styles. Possible values are: + # + # NO_ARGUMENT:: The switch takes no arguments. (:NONE) + # REQUIRED_ARGUMENT:: The switch requires an argument. (:REQUIRED) + # OPTIONAL_ARGUMENT:: The switch requires an optional argument. (:OPTIONAL) + # + # Use like --switch=argument (long style) or -Xargument (short style). For + # short style, only portion matched to argument pattern is treated as + # argument. + # + ArgumentStyle = {} + NoArgument.each {|el| ArgumentStyle[el] = Switch::NoArgument} + RequiredArgument.each {|el| ArgumentStyle[el] = Switch::RequiredArgument} + OptionalArgument.each {|el| ArgumentStyle[el] = Switch::OptionalArgument} + ArgumentStyle.freeze + + # + # Switches common used such as '--', and also provides default + # argument classes + # + DefaultList = List.new + DefaultList.short['-'] = Switch::NoArgument.new {} + DefaultList.long[''] = Switch::NoArgument.new {throw :terminate} + + COMPSYS_HEADER = <<'XXX' # :nodoc: + +typeset -A opt_args +local context state line + +_arguments -s -S \ +XXX + + def compsys(to, name = File.basename($0)) # :nodoc: + to << "#compdef #{name}\n" + to << COMPSYS_HEADER + visit(:compsys, {}, {}) {|o, d| + to << %Q[ "#{o}[#{d.gsub(/[\\\"\[\]]/, '\\\\\&')}]" \\\n] + } + to << " '*:file:_files' && return 0\n" + end + + def help_exit + if $stdout.tty? && (pager = ENV.values_at(*%w[RUBY_PAGER PAGER]).find {|e| e && !e.empty?}) + less = ENV["LESS"] + args = [{"LESS" => "#{less} -Fe"}, pager, "w"] + print = proc do |f| + f.puts help + rescue Errno::EPIPE + # pager terminated + end + if Process.respond_to?(:fork) and false + IO.popen("-") {|f| f ? Process.exec(*args, in: f) : print.call($stdout)} + # unreachable + end + IO.popen(*args, &print) + else + puts help + end + exit + end + + # + # Default options for ARGV, which never appear in option summary. + # + Officious = {} + + # + # --help + # Shows option summary. + # + Officious['help'] = proc do |parser| + Switch::NoArgument.new do |arg| + parser.help_exit + end + end + + # + # --*-completion-bash=WORD + # Shows candidates for command line completion. + # + Officious['*-completion-bash'] = proc do |parser| + Switch::RequiredArgument.new do |arg| + puts parser.candidate(arg) + exit + end + end + + # + # --*-completion-zsh[=NAME:FILE] + # Creates zsh completion file. + # + Officious['*-completion-zsh'] = proc do |parser| + Switch::OptionalArgument.new do |arg| + parser.compsys($stdout, arg) + exit + end + end + + # + # --version + # Shows version string if Version is defined. + # + Officious['version'] = proc do |parser| + Switch::OptionalArgument.new do |pkg| + if pkg + begin + require_relative 'optparse/version' + rescue LoadError + else + show_version(*pkg.split(/,/)) or + abort("#{parser.program_name}: no version found in package #{pkg}") + exit + end + end + v = parser.ver or abort("#{parser.program_name}: version unknown") + puts v + exit + end + end + + # :startdoc: + + # + # Class methods + # + + # + # Initializes a new instance and evaluates the optional block in context + # of the instance. Arguments +args+ are passed to #new, see there for + # description of parameters. + # + # This method is *deprecated*, its behavior corresponds to the older #new + # method. + # + def self.with(*args, &block) + opts = new(*args) + opts.instance_eval(&block) + opts + end + + # + # Returns an incremented value of +default+ according to +arg+. + # + def self.inc(arg, default = nil) + case arg + when Integer + arg.nonzero? + when nil + default.to_i + 1 + end + end + + # + # See self.inc + # + def inc(*args) + self.class.inc(*args) + end + + # + # Initializes the instance and yields itself if called with a block. + # + # +banner+:: Banner message. + # +width+:: Summary width. + # +indent+:: Summary indent. + # + def initialize(banner = nil, width = 32, indent = ' ' * 4) + @stack = [DefaultList, List.new, List.new] + @program_name = nil + @banner = banner + @summary_width = width + @summary_indent = indent + @default_argv = ARGV + @require_exact = false + @raise_unknown = true + add_officious + yield self if block_given? + end + + def add_officious # :nodoc: + list = base() + Officious.each do |opt, block| + list.long[opt] ||= block.call(self) + end + end + + # + # Terminates option parsing. Optional parameter +arg+ is a string pushed + # back to be the first non-option argument. + # + def terminate(arg = nil) + self.class.terminate(arg) + end + # + # See #terminate. + # + def self.terminate(arg = nil) + throw :terminate, arg + end + + @stack = [DefaultList] + # + # Returns the global top option list. + # + # Do not use directly. + # + def self.top() DefaultList end + + # + # Directs to accept specified class +t+. The argument string is passed to + # the block in which it should be converted to the desired class. + # + # +t+:: Argument class specifier, any object including Class. + # +pat+:: Pattern for argument, defaults to +t+ if it responds to match. + # + # accept(t, pat, &block) + # + def accept(*args, &blk) top.accept(*args, &blk) end + # + # See #accept. + # + def self.accept(*args, &blk) top.accept(*args, &blk) end + + # + # Directs to reject specified class argument. + # + # +type+:: Argument class specifier, any object including Class. + # + # reject(type) + # + def reject(*args, &blk) top.reject(*args, &blk) end + # + # See #reject. + # + def self.reject(*args, &blk) top.reject(*args, &blk) end + + # + # Instance methods + # + + # Heading banner preceding summary. + attr_writer :banner + + # Program name to be emitted in error message and default banner, + # defaults to $0. + attr_writer :program_name + + # Width for option list portion of summary. Must be Numeric. + attr_accessor :summary_width + + # Indentation for summary. Must be String (or have + String method). + attr_accessor :summary_indent + + # Strings to be parsed in default. + attr_accessor :default_argv + + # Whether to require that options match exactly (disallows providing + # abbreviated long option as short option). + attr_accessor :require_exact + + # Whether to raise at unknown option. + attr_accessor :raise_unknown + + # + # Heading banner preceding summary. + # + def banner + unless @banner + @banner = +"Usage: #{program_name} [options]" + visit(:add_banner, @banner) + end + @banner + end + + # + # Program name to be emitted in error message and default banner, defaults + # to $0. + # + def program_name + @program_name || strip_ext(File.basename($0)) + end + + private def strip_ext(name) # :nodoc: + exts = /#{ + require "rbconfig" + Regexp.union(*RbConfig::CONFIG["EXECUTABLE_EXTS"]&.split(" ")) + }\z/o + name.sub(exts, "") + end + + # for experimental cascading :-) + alias set_banner banner= + alias set_program_name program_name= + alias set_summary_width summary_width= + alias set_summary_indent summary_indent= + + # Version + attr_writer :version + # Release code + attr_writer :release + + # + # Version + # + def version + (defined?(@version) && @version) || (defined?(::Version) && ::Version) + end + + # + # Release code + # + def release + (defined?(@release) && @release) || (defined?(::Release) && ::Release) || (defined?(::RELEASE) && ::RELEASE) + end + + # + # Returns version string from program_name, version and release. + # + def ver + if v = version + str = +"#{program_name} #{[v].join('.')}" + str << " (#{v})" if v = release + str + end + end + + # + # Shows warning message with the program name + # + # +mesg+:: Message, defaulted to +$!+. + # + # See Kernel#warn. + # + def warn(mesg = $!) + super("#{program_name}: #{mesg}") + end + + # + # Shows message with the program name then aborts. + # + # +mesg+:: Message, defaulted to +$!+. + # + # See Kernel#abort. + # + def abort(mesg = $!) + super("#{program_name}: #{mesg}") + end + + # + # Subject of #on / #on_head, #accept / #reject + # + def top + @stack[-1] + end + + # + # Subject of #on_tail. + # + def base + @stack[1] + end + + # + # Pushes a new List. + # + # If a block is given, yields +self+ and returns the result of the + # block, otherwise returns +self+. + # + def new + @stack.push(List.new) + if block_given? + yield self + else + self + end + end + + # + # Removes the last List. + # + def remove + @stack.pop + end + + # + # Puts option summary into +to+ and returns +to+. Yields each line if + # a block is given. + # + # +to+:: Output destination, which must have method <<. Defaults to []. + # +width+:: Width of left side, defaults to @summary_width. + # +max+:: Maximum length allowed for left side, defaults to +width+ - 1. + # +indent+:: Indentation, defaults to @summary_indent. + # + def summarize(to = [], width = @summary_width, max = width - 1, indent = @summary_indent, &blk) + nl = "\n" + blk ||= proc {|l| to << (l.index(nl, -1) ? l : l + nl)} + visit(:summarize, {}, {}, width, max, indent, &blk) + to + end + + # + # Returns option summary string. + # + def help; summarize("#{banner}".sub(/\n?\z/, "\n")) end + alias to_s help + + def pretty_print(q) # :nodoc: + q.object_group(self) do + first = true + if @stack.size > 2 + @stack.each_with_index do |s, i| + next if i < 2 + next if s.list.empty? + if first + first = false + q.text ":" + end + q.breakable + s.pretty_print(q) + end + end + end + end + + def inspect # :nodoc: + require 'pp' + pretty_print_inspect + end + + # + # Returns option summary list. + # + def to_a; summarize("#{banner}".split(/^/)) end + + # + # Checks if an argument is given twice, in which case an ArgumentError is + # raised. Called from Gem::OptionParser#switch only. + # + # +obj+:: New argument. + # +prv+:: Previously specified argument. + # +msg+:: Exception message. + # + def notwice(obj, prv, msg) # :nodoc: + unless !prv or prv == obj + raise(ArgumentError, "argument #{msg} given twice: #{obj}", + ParseError.filter_backtrace(caller(2))) + end + obj + end + private :notwice + + SPLAT_PROC = proc {|*a| a.length <= 1 ? a.first : a} # :nodoc: + + # :call-seq: + # make_switch(params, block = nil) + # + # :include: ../doc/optparse/creates_option.rdoc + # + def make_switch(opts, block = nil) + short, long, nolong, style, pattern, conv, not_pattern, not_conv, not_style = [], [], [] + ldesc, sdesc, desc, arg = [], [], [] + default_style = Switch::NoArgument + default_pattern = nil + klass = nil + q, a = nil + has_arg = false + values = nil + + opts.each do |o| + # argument class + next if search(:atype, o) do |pat, c| + klass = notwice(o, klass, 'type') + if not_style and not_style != Switch::NoArgument + not_pattern, not_conv = pat, c + else + default_pattern, conv = pat, c + end + end + + # directly specified pattern(any object possible to match) + if !Completion.completable?(o) and o.respond_to?(:match) + pattern = notwice(o, pattern, 'pattern') + if pattern.respond_to?(:convert) + conv = pattern.method(:convert).to_proc + else + conv = SPLAT_PROC + end + next + end + + # anything others + case o + when Proc, Method + block = notwice(o, block, 'block') + when Array, Hash, Set + if Array === o + o, v = o.partition {|v,| Completion.completable?(v)} + values = notwice(v, values, 'values') unless v.empty? + next if o.empty? + end + case pattern + when CompletingHash + when nil + pattern = CompletingHash.new + conv = pattern.method(:convert).to_proc if pattern.respond_to?(:convert) + else + raise ArgumentError, "argument pattern given twice" + end + o.each {|pat, *v| pattern[pat] = v.fetch(0) {pat}} + when Range + values = notwice(o, values, 'values') + when Module + raise ArgumentError, "unsupported argument type: #{o}", ParseError.filter_backtrace(caller(4)) + when *ArgumentStyle.keys + style = notwice(ArgumentStyle[o], style, 'style') + when /\A--no-([^\[\]=\s]*)(.+)?/ + q, a = $1, $2 + o = notwice(a ? Object : TrueClass, klass, 'type') + not_pattern, not_conv = search(:atype, o) unless not_style + not_style = (not_style || default_style).guess(arg = a) if a + default_style = Switch::NoArgument + default_pattern, conv = search(:atype, FalseClass) unless default_pattern + ldesc << "--no-#{q}" + (q = q.downcase).tr!('_', '-') + long << "no-#{q}" + nolong << q + when /\A--\[no-\]([^\[\]=\s]*)(.+)?/ + q, a = $1, $2 + o = notwice(a ? Object : TrueClass, klass, 'type') + if a + default_style = default_style.guess(arg = a) + default_pattern, conv = search(:atype, o) unless default_pattern + end + ldesc << "--[no-]#{q}" + (o = q.downcase).tr!('_', '-') + long << o + not_pattern, not_conv = search(:atype, FalseClass) unless not_style + not_style = Switch::NoArgument + nolong << "no-#{o}" + when /\A--([^\[\]=\s]*)(.+)?/ + q, a = $1, $2 + if a + o = notwice(NilClass, klass, 'type') + default_style = default_style.guess(arg = a) + default_pattern, conv = search(:atype, o) unless default_pattern + end + ldesc << "--#{q}" + (o = q.downcase).tr!('_', '-') + long << o + when /\A-(\[\^?\]?(?:[^\\\]]|\\.)*\])(.+)?/ + q, a = $1, $2 + o = notwice(Object, klass, 'type') + if a + default_style = default_style.guess(arg = a) + default_pattern, conv = search(:atype, o) unless default_pattern + else + has_arg = true + end + sdesc << "-#{q}" + short << Regexp.new(q) + when /\A-(.)(.+)?/ + q, a = $1, $2 + if a + o = notwice(NilClass, klass, 'type') + default_style = default_style.guess(arg = a) + default_pattern, conv = search(:atype, o) unless default_pattern + end + sdesc << "-#{q}" + short << q + when /\A=/ + style = notwice(default_style.guess(arg = o), style, 'style') + default_pattern, conv = search(:atype, Object) unless default_pattern + else + desc.push(o) if o && !o.empty? + end + end + + default_pattern, conv = search(:atype, default_style.pattern) unless default_pattern + if Range === values and klass + unless (!values.begin or klass === values.begin) and + (!values.end or klass === values.end) + raise ArgumentError, "range does not match class" + end + end + if !(short.empty? and long.empty?) + if has_arg and default_style == Switch::NoArgument + default_style = Switch::RequiredArgument + end + s = (style || default_style).new(pattern || default_pattern, + conv, sdesc, ldesc, arg, desc, block, values) + elsif !block + if style or pattern + raise ArgumentError, "no switch given", ParseError.filter_backtrace(caller) + end + s = desc + else + short << pattern + s = (style || default_style).new(pattern, + conv, nil, nil, arg, desc, block, values) + end + return s, short, long, + (not_style.new(not_pattern, not_conv, sdesc, ldesc, nil, desc, block) if not_style), + nolong + end + + # ---- + # Option definition phase methods + # + # These methods are used to define options, or to construct an + # Gem::OptionParser instance in other words. + + # :call-seq: + # define(*params, &block) + # + # :include: ../doc/optparse/creates_option.rdoc + # + def define(*opts, &block) + top.append(*(sw = make_switch(opts, block))) + sw[0] + end + + # :call-seq: + # on(*params, &block) + # + # :include: ../doc/optparse/creates_option.rdoc + # + def on(*opts, &block) + define(*opts, &block) + self + end + alias def_option define + + # :call-seq: + # define_head(*params, &block) + # + # :include: ../doc/optparse/creates_option.rdoc + # + def define_head(*opts, &block) + top.prepend(*(sw = make_switch(opts, block))) + sw[0] + end + + # :call-seq: + # on_head(*params, &block) + # + # :include: ../doc/optparse/creates_option.rdoc + # + # The new option is added at the head of the summary. + # + def on_head(*opts, &block) + define_head(*opts, &block) + self + end + alias def_head_option define_head + + # :call-seq: + # define_tail(*params, &block) + # + # :include: ../doc/optparse/creates_option.rdoc + # + def define_tail(*opts, &block) + base.append(*(sw = make_switch(opts, block))) + sw[0] + end + + # + # :call-seq: + # on_tail(*params, &block) + # + # :include: ../doc/optparse/creates_option.rdoc + # + # The new option is added at the tail of the summary. + # + def on_tail(*opts, &block) + define_tail(*opts, &block) + self + end + alias def_tail_option define_tail + + # + # Add separator in summary. + # + def separator(string) + top.append(string, nil, nil) + end + + # ---- + # Arguments parse phase methods + # + # These methods parse +argv+, convert, and store the results by + # calling handlers. As these methods do not modify +self+, +self+ + # can be frozen. + + # + # Parses command line arguments +argv+ in order. When a block is given, + # each non-option argument is yielded. When optional +into+ keyword + # argument is provided, the parsed option values are stored there via + # <code>[]=</code> method (so it can be Hash, or OpenStruct, or other + # similar object). + # + # Returns the rest of +argv+ left unparsed. + # + def order(*argv, **keywords, &nonopt) + argv = argv[0].dup if argv.size == 1 and Array === argv[0] + order!(argv, **keywords, &nonopt) + end + + # + # Same as #order, but removes switches destructively. + # Non-option arguments remain in +argv+. + # + def order!(argv = default_argv, into: nil, **keywords, &nonopt) + setter = ->(name, val) {into[name.to_sym] = val} if into + parse_in_order(argv, setter, **keywords, &nonopt) + end + + def parse_in_order(argv = default_argv, setter = nil, exact: require_exact, **, &nonopt) # :nodoc: + opt, arg, val, rest = nil + nonopt ||= proc {|a| throw :terminate, a} + argv.unshift(arg) if arg = catch(:terminate) { + while arg = argv.shift + case arg + # long option + when /\A--([^=]*)(?:=(.*))?/m + opt, rest = $1, $2 + opt.tr!('_', '-') + begin + if exact + sw, = search(:long, opt) + else + sw, = complete(:long, opt, true) + end + rescue ParseError + throw :terminate, arg unless raise_unknown + raise $!.set_option(arg, true) + else + unless sw + throw :terminate, arg unless raise_unknown + raise InvalidOption, arg + end + end + begin + opt, cb, val = sw.parse(rest, argv) {|*exc| raise(*exc)} + val = callback!(cb, 1, val) if cb + callback!(setter, 2, sw.switch_name, val) if setter + rescue ParseError + raise $!.set_option(arg, rest) + end + + # short option + when /\A-(.)((=).*|.+)?/m + eq, rest, opt = $3, $2, $1 + has_arg, val = eq, rest + begin + sw, = search(:short, opt) + unless sw + begin + sw, = complete(:short, opt) + # short option matched. + val = arg.delete_prefix('-') + has_arg = true + rescue InvalidOption + raise if exact + # if no short options match, try completion with long + # options. + sw, = complete(:long, opt) + eq ||= !rest + end + end + rescue ParseError + throw :terminate, arg unless raise_unknown + raise $!.set_option(arg, true) + end + begin + opt, cb, val = sw.parse(val, argv) {|*exc| raise(*exc) if eq} + rescue ParseError + raise $!.set_option(arg, arg.length > 2) + else + raise InvalidOption, arg if has_arg and !eq and arg == "-#{opt}" + end + begin + argv.unshift(opt) if opt and (!rest or (opt = opt.sub(/\A-*/, '-')) != '-') + val = callback!(cb, 1, val) if cb + callback!(setter, 2, sw.switch_name, val) if setter + rescue ParseError + raise $!.set_option(arg, arg.length > 2) + end + + # non-option argument + else + catch(:prune) do + visit(:each_option) do |sw0| + sw = sw0 + sw.block.call(arg) if Switch === sw and sw.match_nonswitch?(arg) + end + nonopt.call(arg) + end + end + end + + nil + } + + visit(:search, :short, nil) {|sw| sw.block.call(*argv) if !sw.pattern} + + argv + end + private :parse_in_order + + # Calls callback with _val_. + def callback!(cb, max_arity, *args) # :nodoc: + args.compact! + + if (size = args.size) < max_arity and cb.to_proc.lambda? + (arity = cb.arity) < 0 and arity = (1-arity) + arity = max_arity if arity > max_arity + args[arity - 1] = nil if arity > size + end + cb.call(*args) + end + private :callback! + + # + # Parses command line arguments +argv+ in permutation mode and returns + # list of non-option arguments. When optional +into+ keyword + # argument is provided, the parsed option values are stored there via + # <code>[]=</code> method (so it can be Hash, or OpenStruct, or other + # similar object). + # + def permute(*argv, **keywords) + argv = argv[0].dup if argv.size == 1 and Array === argv[0] + permute!(argv, **keywords) + end + + # + # Same as #permute, but removes switches destructively. + # Non-option arguments remain in +argv+. + # + def permute!(argv = default_argv, **keywords) + nonopts = [] + order!(argv, **keywords) {|nonopt| nonopts << nonopt} + argv[0, 0] = nonopts + argv + end + + # + # Parses command line arguments +argv+ in order when environment variable + # POSIXLY_CORRECT is set, and in permutation mode otherwise. + # When optional +into+ keyword argument is provided, the parsed option + # values are stored there via <code>[]=</code> method (so it can be Hash, + # or OpenStruct, or other similar object). + # + def parse(*argv, **keywords) + argv = argv[0].dup if argv.size == 1 and Array === argv[0] + parse!(argv, **keywords) + end + + # + # Same as #parse, but removes switches destructively. + # Non-option arguments remain in +argv+. + # + def parse!(argv = default_argv, **keywords) + if ENV.include?('POSIXLY_CORRECT') + order!(argv, **keywords) + else + permute!(argv, **keywords) + end + end + + # + # Wrapper method for getopts.rb. + # + # params = ARGV.getopts("ab:", "foo", "bar:", "zot:Z;zot option") + # # params["a"] = true # -a + # # params["b"] = "1" # -b1 + # # params["foo"] = "1" # --foo + # # params["bar"] = "x" # --bar x + # # params["zot"] = "z" # --zot Z + # + # Option +symbolize_names+ (boolean) specifies whether returned Hash keys should be Symbols; defaults to +false+ (use Strings). + # + # params = ARGV.getopts("ab:", "foo", "bar:", "zot:Z;zot option", symbolize_names: true) + # # params[:a] = true # -a + # # params[:b] = "1" # -b1 + # # params[:foo] = "1" # --foo + # # params[:bar] = "x" # --bar x + # # params[:zot] = "z" # --zot Z + # + def getopts(*args, symbolize_names: false, **keywords) + argv = Array === args.first ? args.shift : default_argv + single_options, *long_options = *args + + result = {} + setter = (symbolize_names ? + ->(name, val) {result[name.to_sym] = val} + : ->(name, val) {result[name] = val}) + + single_options.scan(/(.)(:)?/) do |opt, val| + if val + setter[opt, nil] + define("-#{opt} VAL") + else + setter[opt, false] + define("-#{opt}") + end + end if single_options + + long_options.each do |arg| + arg, desc = arg.split(';', 2) + opt, val = arg.split(':', 2) + if val + setter[opt, (val unless val.empty?)] + define("--#{opt}=#{result[opt] || "VAL"}", *[desc].compact) + else + setter[opt, false] + define("--#{opt}", *[desc].compact) + end + end + + parse_in_order(argv, setter, **keywords) + result + end + + # + # See #getopts. + # + def self.getopts(*args, symbolize_names: false) + new.getopts(*args, symbolize_names: symbolize_names) + end + + # + # Traverses @stack, sending each element method +id+ with +args+ and + # +block+. + # + def visit(id, *args, &block) # :nodoc: + @stack.reverse_each do |el| + el.__send__(id, *args, &block) + end + nil + end + private :visit + + # + # Searches +key+ in @stack for +id+ hash and returns or yields the result. + # + def search(id, key) # :nodoc: + block_given = block_given? + visit(:search, id, key) do |k| + return block_given ? yield(k) : k + end + end + private :search + + # + # Completes shortened long style option switch and returns pair of + # canonical switch and switch descriptor Gem::OptionParser::Switch. + # + # +typ+:: Searching table. + # +opt+:: Searching key. + # +icase+:: Search case insensitive if true. + # +pat+:: Optional pattern for completion. + # + def complete(typ, opt, icase = false, *pat) # :nodoc: + if pat.empty? + search(typ, opt) {|sw| return [sw, opt]} # exact match or... + end + ambiguous = catch(:ambiguous) { + visit(:complete, typ, opt, icase, *pat) {|o, *sw| return sw} + } + exc = ambiguous ? AmbiguousOption : InvalidOption + raise exc.new(opt, additional: proc {|o| additional_message(typ, o)}) + end + private :complete + + # + # Returns additional info. + # + def additional_message(typ, opt) + return unless typ and opt and defined?(DidYouMean::SpellChecker) + all_candidates = [] + visit(:get_candidates, typ) do |candidates| + all_candidates.concat(candidates) + end + all_candidates.select! {|cand| cand.is_a?(String) } + checker = DidYouMean::SpellChecker.new(dictionary: all_candidates) + DidYouMean.formatter.message_for(all_candidates & checker.correct(opt)) + end + + # + # Return candidates for +word+. + # + def candidate(word) + list = [] + case word + when '-' + long = short = true + when /\A--/ + word, arg = word.split(/=/, 2) + argpat = Completion.regexp(arg, false) if arg and !arg.empty? + long = true + when /\A-/ + short = true + end + pat = Completion.regexp(word, long) + visit(:each_option) do |opt| + next unless Switch === opt + opts = (long ? opt.long : []) + (short ? opt.short : []) + opts = Completion.candidate(word, true, pat, &opts.method(:each)).map(&:first) if pat + if /\A=/ =~ opt.arg + opts.map! {|sw| sw + "="} + if arg and CompletingHash === opt.pattern + if opts = opt.pattern.candidate(arg, false, argpat) + opts.map!(&:last) + end + end + end + list.concat(opts) + end + list + end + + # + # Loads options from file names as +filename+. Does nothing when the file + # is not present. Returns whether successfully loaded. + # + # +filename+ defaults to basename of the program without suffix in a + # directory ~/.options, then the basename with '.options' suffix + # under XDG and Haiku standard places. + # + # The optional +into+ keyword argument works exactly like that accepted in + # method #parse. + # + def load(filename = nil, **keywords) + unless filename + basename = File.basename($0, '.*') + return true if load(File.expand_path("~/.options/#{basename}"), **keywords) rescue nil + basename << ".options" + if !(xdg = ENV['XDG_CONFIG_HOME']) or xdg.empty? + # https://specifications.freedesktop.org/basedir-spec/latest/#variables + # + # If $XDG_CONFIG_HOME is either not set or empty, a default + # equal to $HOME/.config should be used. + xdg = ['~/.config', true] + end + return [ + xdg, + + *ENV['XDG_CONFIG_DIRS']&.split(File::PATH_SEPARATOR), + + # Haiku + ['~/config/settings', true], + ].any? {|dir, expand| + next if !dir or dir.empty? + filename = File.join(dir, basename) + filename = File.expand_path(filename) if expand + load(filename, **keywords) rescue nil + } + end + begin + parse(*File.readlines(filename, chomp: true), **keywords) + true + rescue Errno::ENOENT, Errno::ENOTDIR + false + end + end + + # + # Parses environment variable +env+ or its uppercase with splitting like a + # shell. + # + # +env+ defaults to the basename of the program. + # + def environment(env = File.basename($0, '.*'), **keywords) + env = ENV[env] || ENV[env.upcase] or return + require 'shellwords' + parse(*Shellwords.shellwords(env), **keywords) + end + + # + # Acceptable argument classes + # + + # + # Any string and no conversion. This is fall-back. + # + accept(Object) {|s,|s or s.nil?} + + accept(NilClass) {|s,|s} + + # + # Any non-empty string, and no conversion. + # + accept(String, /.+/m) {|s,*|s} + + # + # Ruby/C-like integer, octal for 0-7 sequence, binary for 0b, hexadecimal + # for 0x, and decimal for others; with optional sign prefix. Converts to + # Integer. + # + decimal = '\d+(?:_\d+)*' + binary = 'b[01]+(?:_[01]+)*' + hex = 'x[\da-f]+(?:_[\da-f]+)*' + octal = "0(?:[0-7]+(?:_[0-7]+)*|#{binary}|#{hex})?" + integer = "#{octal}|#{decimal}" + + accept(Integer, %r"\A[-+]?(?:#{integer})\z"io) {|s,| + begin + Integer(s) + rescue ArgumentError + raise Gem::OptionParser::InvalidArgument, s + end if s + } + + # + # Float number format, and converts to Float. + # + float = "(?:#{decimal}(?=(.)?)(?:\\.(?:#{decimal})?)?|\\.#{decimal})(?:E[-+]?#{decimal})?" + floatpat = %r"\A[-+]?#{float}\z"io + accept(Float, floatpat) {|s,| s.to_f if s} + + # + # Generic numeric format, converts to Integer for integer format, Float + # for float format, and Rational for rational format. + # + real = "[-+]?(?:#{octal}|#{float})" + accept(Numeric, /\A(#{real})(?:\/(#{real}))?\z/io) {|s, d, f, n,| + if n + Rational(d, n) + elsif f + Float(s) + else + Integer(s) + end + } + + # + # Decimal integer format, to be converted to Integer. + # + DecimalInteger = /\A[-+]?#{decimal}\z/io + accept(DecimalInteger, DecimalInteger) {|s,| + begin + Integer(s, 10) + rescue ArgumentError + raise Gem::OptionParser::InvalidArgument, s + end if s + } + + # + # Ruby/C like octal/hexadecimal/binary integer format, to be converted to + # Integer. + # + OctalInteger = /\A[-+]?(?:[0-7]+(?:_[0-7]+)*|0(?:#{binary}|#{hex}))\z/io + accept(OctalInteger, OctalInteger) {|s,| + begin + Integer(s, 8) + rescue ArgumentError + raise Gem::OptionParser::InvalidArgument, s + end if s + } + + # + # Decimal integer/float number format, to be converted to Integer for + # integer format, Float for float format. + # + DecimalNumeric = floatpat # decimal integer is allowed as float also. + accept(DecimalNumeric, floatpat) {|s, f| + begin + if f + Float(s) + else + Integer(s) + end + rescue ArgumentError + raise Gem::OptionParser::InvalidArgument, s + end if s + } + + # + # Boolean switch, which means whether it is present or not, whether it is + # absent or not with prefix no-, or it takes an argument + # yes/no/true/false/+/-. + # + yesno = CompletingHash.new + %w[- no false].each {|el| yesno[el] = false} + %w[+ yes true].each {|el| yesno[el] = true} + yesno['nil'] = false # should be nil? + accept(TrueClass, yesno) {|arg, val| val == nil or val} + # + # Similar to TrueClass, but defaults to false. + # + accept(FalseClass, yesno) {|arg, val| val != nil and val} + + # + # List of strings separated by ",". + # + accept(Array) do |s, | + if s + s = s.split(',').collect {|ss| ss unless ss.empty?} + end + s + end + + # + # Regular expression with options. + # + accept(Regexp, %r"\A/((?:\\.|[^\\])*)/([[:alpha:]]+)?\z|.*") do |all, s, o| + f = 0 + if o + f |= Regexp::IGNORECASE if /i/ =~ o + f |= Regexp::MULTILINE if /m/ =~ o + f |= Regexp::EXTENDED if /x/ =~ o + case o = o.delete("imx") + when "" + when "u" + s = s.encode(Encoding::UTF_8) + when "e" + s = s.encode(Encoding::EUC_JP) + when "s" + s = s.encode(Encoding::SJIS) + when "n" + f |= Regexp::NOENCODING + else + raise Gem::OptionParser::InvalidArgument, "unknown regexp option - #{o}" + end + else + s ||= all + end + Regexp.new(s, f) + end + + # + # Exceptions + # + + # + # Base class of exceptions from Gem::OptionParser. + # + class ParseError < RuntimeError + # Reason which caused the error. + Reason = 'parse error' + + # :nodoc: + def initialize(*args, additional: nil) + @additional = additional + @arg0, = args + @args = args + @reason = nil + end + + attr_reader :args + attr_writer :reason + attr_accessor :additional + + # + # Pushes back erred argument(s) to +argv+. + # + def recover(argv) + argv[0, 0] = @args + argv + end + + DIR = File.join(__dir__, '') + def self.filter_backtrace(array) + unless $DEBUG + array.delete_if {|bt| bt.start_with?(DIR)} + end + array + end + + def set_backtrace(array) + super(self.class.filter_backtrace(array)) + end + + def set_option(opt, eq) + if eq + @args[0] = opt + else + @args.unshift(opt) + end + self + end + + # + # Returns error reason. Override this for I18N. + # + def reason + @reason || self.class::Reason + end + + def inspect + "#<#{self.class}: #{args.join(' ')}>" + end + + # + # Default stringizing method to emit standard error message. + # + def message + "#{reason}: #{args.join(' ')}#{additional[@arg0] if additional}" + end + + alias to_s message + end + + # + # Raises when ambiguously completable string is encountered. + # + class AmbiguousOption < ParseError + const_set(:Reason, 'ambiguous option') + end + + # + # Raises when there is an argument for a switch which takes no argument. + # + class NeedlessArgument < ParseError + const_set(:Reason, 'needless argument') + end + + # + # Raises when a switch with mandatory argument has no argument. + # + class MissingArgument < ParseError + const_set(:Reason, 'missing argument') + end + + # + # Raises when switch is undefined. + # + class InvalidOption < ParseError + const_set(:Reason, 'invalid option') + end + + # + # Raises when the given argument does not match required format. + # + class InvalidArgument < ParseError + const_set(:Reason, 'invalid argument') + end + + # + # Raises when the given argument word can't be completed uniquely. + # + class AmbiguousArgument < InvalidArgument + const_set(:Reason, 'ambiguous argument') + end + + # + # Miscellaneous + # + + # + # Extends command line arguments array (ARGV) to parse itself. + # + module Arguable + + # + # Sets Gem::OptionParser object, when +opt+ is +false+ or +nil+, methods + # Gem::OptionParser::Arguable#options and Gem::OptionParser::Arguable#options= are + # undefined. Thus, there is no ways to access the Gem::OptionParser object + # via the receiver object. + # + def options=(opt) + unless @optparse = opt + class << self + undef_method(:options) + undef_method(:options=) + end + end + end + + # + # Actual Gem::OptionParser object, automatically created if nonexistent. + # + # If called with a block, yields the Gem::OptionParser object and returns the + # result of the block. If an Gem::OptionParser::ParseError exception occurs + # in the block, it is rescued, a error message printed to STDERR and + # +nil+ returned. + # + def options + @optparse ||= Gem::OptionParser.new + @optparse.default_argv = self + block_given? or return @optparse + begin + yield @optparse + rescue ParseError + @optparse.warn $! + nil + end + end + + # + # Parses +self+ destructively in order and returns +self+ containing the + # rest arguments left unparsed. + # + def order!(**keywords, &blk) options.order!(self, **keywords, &blk) end + + # + # Parses +self+ destructively in permutation mode and returns +self+ + # containing the rest arguments left unparsed. + # + def permute!(**keywords) options.permute!(self, **keywords) end + + # + # Parses +self+ destructively and returns +self+ containing the + # rest arguments left unparsed. + # + def parse!(**keywords) options.parse!(self, **keywords) end + + # + # Substitution of getopts is possible as follows. Also see + # Gem::OptionParser#getopts. + # + # def getopts(*args) + # ($OPT = ARGV.getopts(*args)).each do |opt, val| + # eval "$OPT_#{opt.gsub(/[^A-Za-z0-9_]/, '_')} = val" + # end + # rescue Gem::OptionParser::ParseError + # end + # + def getopts(*args, symbolize_names: false, **keywords) + options.getopts(self, *args, symbolize_names: symbolize_names, **keywords) + end + + # + # Initializes instance variable. + # + def self.extend_object(obj) + super + obj.instance_eval {@optparse = nil} + end + + def initialize(*args) # :nodoc: + super + @optparse = nil + end + end + + # + # Acceptable argument classes. Now contains DecimalInteger, OctalInteger + # and DecimalNumeric. See Acceptable argument classes (in source code). + # + module Acceptables + const_set(:DecimalInteger, Gem::OptionParser::DecimalInteger) + const_set(:OctalInteger, Gem::OptionParser::OctalInteger) + const_set(:DecimalNumeric, Gem::OptionParser::DecimalNumeric) + end +end + +# ARGV is arguable by Gem::OptionParser +ARGV.extend(Gem::OptionParser::Arguable) diff --git a/lib/rubygems/vendor/optparse/lib/optparse/ac.rb b/lib/rubygems/vendor/optparse/lib/optparse/ac.rb new file mode 100644 index 0000000000..28a5b1b33e --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/ac.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: false +require_relative '../optparse' + +# +# autoconf-like options. +# +class Gem::OptionParser::AC < Gem::OptionParser + # :stopdoc: + private + + def _check_ac_args(name, block) + unless /\A\w[-\w]*\z/ =~ name + raise ArgumentError, name + end + unless block + raise ArgumentError, "no block given", ParseError.filter_backtrace(caller) + end + end + + ARG_CONV = proc {|val| val.nil? ? true : val} + private_constant :ARG_CONV + + def _ac_arg_enable(prefix, name, help_string, block) + _check_ac_args(name, block) + + sdesc = [] + ldesc = ["--#{prefix}-#{name}"] + desc = [help_string] + q = name.downcase + ac_block = proc {|val| block.call(ARG_CONV.call(val))} + enable = Switch::PlacedArgument.new(nil, ARG_CONV, sdesc, ldesc, nil, desc, ac_block) + disable = Switch::NoArgument.new(nil, proc {false}, sdesc, ldesc, nil, desc, ac_block) + top.append(enable, [], ["enable-" + q], disable, ['disable-' + q]) + enable + end + + # :startdoc: + + public + + # Define <tt>--enable</tt> / <tt>--disable</tt> style option + # + # Appears as <tt>--enable-<i>name</i></tt> in help message. + def ac_arg_enable(name, help_string, &block) + _ac_arg_enable("enable", name, help_string, block) + end + + # Define <tt>--enable</tt> / <tt>--disable</tt> style option + # + # Appears as <tt>--disable-<i>name</i></tt> in help message. + def ac_arg_disable(name, help_string, &block) + _ac_arg_enable("disable", name, help_string, block) + end + + # Define <tt>--with</tt> / <tt>--without</tt> style option + # + # Appears as <tt>--with-<i>name</i></tt> in help message. + def ac_arg_with(name, help_string, &block) + _check_ac_args(name, block) + + sdesc = [] + ldesc = ["--with-#{name}"] + desc = [help_string] + q = name.downcase + with = Switch::PlacedArgument.new(*search(:atype, String), sdesc, ldesc, nil, desc, block) + without = Switch::NoArgument.new(nil, proc {}, sdesc, ldesc, nil, desc, block) + top.append(with, [], ["with-" + q], without, ['without-' + q]) + with + end +end diff --git a/lib/rubygems/vendor/optparse/lib/optparse/date.rb b/lib/rubygems/vendor/optparse/lib/optparse/date.rb new file mode 100644 index 0000000000..d9a9f4f48a --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/date.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: false +require_relative '../optparse' +require 'date' + +Gem::OptionParser.accept(DateTime) do |s,| + begin + DateTime.parse(s) if s + rescue ArgumentError + raise Gem::OptionParser::InvalidArgument, s + end +end +Gem::OptionParser.accept(Date) do |s,| + begin + Date.parse(s) if s + rescue ArgumentError + raise Gem::OptionParser::InvalidArgument, s + end +end diff --git a/lib/rubygems/vendor/optparse/lib/optparse/kwargs.rb b/lib/rubygems/vendor/optparse/lib/optparse/kwargs.rb new file mode 100644 index 0000000000..70762f033b --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/kwargs.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +require_relative '../optparse' + +class Gem::OptionParser + # :call-seq: + # define_by_keywords(options, method, **params) + # + # :include: ../../doc/optparse/creates_option.rdoc + # + # Defines options which set in to _options_ for keyword parameters + # of _method_. + # + # Parameters for each keywords are given as elements of _params_. + # + def define_by_keywords(options, method, **params) + method.parameters.each do |type, name| + case type + when :key, :keyreq + op, cl = *(type == :key ? %w"[ ]" : ["", ""]) + define("--#{name}=#{op}#{name.upcase}#{cl}", *params[name]) do |o| + options[name] = o + end + end + end + options + end +end diff --git a/lib/rubygems/vendor/optparse/lib/optparse/shellwords.rb b/lib/rubygems/vendor/optparse/lib/optparse/shellwords.rb new file mode 100644 index 0000000000..d47ad60255 --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/shellwords.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: false +# -*- ruby -*- + +require 'shellwords' +require_relative '../optparse' + +Gem::OptionParser.accept(Shellwords) {|s,| Shellwords.shellwords(s)} diff --git a/lib/rubygems/vendor/optparse/lib/optparse/time.rb b/lib/rubygems/vendor/optparse/lib/optparse/time.rb new file mode 100644 index 0000000000..c59e1e4ced --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/time.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: false +require_relative '../optparse' +require 'time' + +Gem::OptionParser.accept(Time) do |s,| + begin + (Time.httpdate(s) rescue Time.parse(s)) if s + rescue + raise Gem::OptionParser::InvalidArgument, s + end +end diff --git a/lib/rubygems/vendor/optparse/lib/optparse/uri.rb b/lib/rubygems/vendor/optparse/lib/optparse/uri.rb new file mode 100644 index 0000000000..398127479a --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/uri.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: false +# -*- ruby -*- + +require_relative '../optparse' +require_relative '../../../uri/lib/uri' + +Gem::OptionParser.accept(Gem::URI) {|s,| Gem::URI.parse(s) if s} diff --git a/lib/rubygems/vendor/optparse/lib/optparse/version.rb b/lib/rubygems/vendor/optparse/lib/optparse/version.rb new file mode 100644 index 0000000000..e39889ae87 --- /dev/null +++ b/lib/rubygems/vendor/optparse/lib/optparse/version.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: false +# Gem::OptionParser internal utility + +class << Gem::OptionParser + # + # Shows version string in packages if Version is defined. + # + # +pkgs+:: package list + # + def show_version(*pkgs) + progname = ARGV.options.program_name + result = false + show = proc do |klass, cname, version| + str = "#{progname}" + unless klass == ::Object and cname == :VERSION + version = version.join(".") if Array === version + str << ": #{klass}" unless klass == Object + str << " version #{version}" + end + [:Release, :RELEASE].find do |rel| + if klass.const_defined?(rel) + str << " (#{klass.const_get(rel)})" + end + end + puts str + result = true + end + if pkgs.size == 1 and pkgs[0] == "all" + self.search_const(::Object, /\AV(?:ERSION|ersion)\z/) do |klass, cname, version| + unless cname[1] == ?e and klass.const_defined?(:Version) + show.call(klass, cname.intern, version) + end + end + else + pkgs.each do |pkg| + begin + pkg = pkg.split(/::|\//).inject(::Object) {|m, c| m.const_get(c)} + v = case + when pkg.const_defined?(:Version) + pkg.const_get(n = :Version) + when pkg.const_defined?(:VERSION) + pkg.const_get(n = :VERSION) + else + n = nil + "unknown" + end + show.call(pkg, n, v) + rescue NameError + end + end + end + result + end + + # :stopdoc: + + def each_const(path, base = ::Object) + path.split(/::|\//).inject(base) do |klass, name| + raise NameError, path unless Module === klass + klass.constants.grep(/#{name}/i) do |c| + klass.const_defined?(c) or next + klass.const_get(c) + end + end + end + + def search_const(klass, name) + klasses = [klass] + while klass = klasses.shift + klass.constants.each do |cname| + klass.const_defined?(cname) or next + const = klass.const_get(cname) + yield klass, cname, const if name === cname + klasses << const if Module === const and const != ::Object + end + end + end + + # :startdoc: +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb new file mode 100644 index 0000000000..818e947477 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb @@ -0,0 +1,53 @@ +require_relative "pub_grub/package" +require_relative "pub_grub/static_package_source" +require_relative "pub_grub/term" +require_relative "pub_grub/version_range" +require_relative "pub_grub/version_constraint" +require_relative "pub_grub/version_union" +require_relative "pub_grub/version_solver" +require_relative "pub_grub/incompatibility" +require_relative 'pub_grub/solve_failure' +require_relative 'pub_grub/failure_writer' +require_relative 'pub_grub/version' + +module Gem::PubGrub + # Minimal logger that doesn't require the 'logger' gem + class NullLogger + def info(&block); end + def debug(&block); end + def warn(&block); end + def error(&block); end + end + + class StderrLogger + def info(&block) + $stderr.puts "INFO: #{block.call}" if block + end + + def debug(&block) + $stderr.puts "DEBUG: #{block.call}" if block + end + + def warn(&block) + $stderr.puts "WARN: #{block.call}" if block + end + + def error(&block) + $stderr.puts "ERROR: #{block.call}" if block + end + end + + class << self + attr_writer :logger + + def logger + @logger || default_logger + end + + private + + def default_logger + @logger = $DEBUG ? StderrLogger.new : NullLogger.new + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb new file mode 100644 index 0000000000..7a11cf0933 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb @@ -0,0 +1,20 @@ +module Gem::PubGrub + class Assignment + attr_reader :term, :cause, :decision_level, :index + def initialize(term, cause, decision_level, index) + @term = term + @cause = cause + @decision_level = decision_level + @index = index + end + + def self.decision(package, version, decision_level, index) + term = Term.new(VersionConstraint.exact(package, version), true) + new(term, :decision, decision_level, index) + end + + def decision? + cause == :decision + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb new file mode 100644 index 0000000000..c8dbf2a5ab --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb @@ -0,0 +1,169 @@ +require_relative 'version_constraint' +require_relative 'incompatibility' + +module Gem::PubGrub + # Types: + # + # Where possible, Gem::PubGrub will accept user-defined types, so long as they quack. + # + # ## "Package": + # + # This class will be used to represent the various packages being solved for. + # .to_s will be called when displaying errors and debugging info, it should + # probably return the package's name. + # It must also have a reasonable definition of #== and #hash + # + # Example classes: String ("rails") + # + # + # ## "Version": + # + # This class will be used to represent a single version number. + # + # Versions don't need to store their associated package, however they will + # only be compared against other versions of the same package. + # + # It must be Comparible (and implement <=> reasonably) + # + # Example classes: Gem::Version, Integer + # + # + # ## "Dependency" + # + # This class represents the requirement one package has on another. It is + # returned by dependencies_for(package, version) and will be passed to + # parse_dependency to convert it to a format Gem::PubGrub understands. + # + # It must also have a reasonable definition of #== + # + # Example classes: String ("~> 1.0"), Gem::Requirement + # + class BasicPackageSource + # Override me! + # + # This is called per package to find all possible versions of a package. + # + # It is called at most once per-package + # + # Returns: Array of versions for a package, in preferred order of selection + def all_versions_for(package) + raise NotImplementedError + end + + # Override me! + # + # Returns: Hash in the form of { package => requirement, ... } + def dependencies_for(package, version) + raise NotImplementedError + end + + # Override me! + # + # Convert a (user-defined) dependency into a format Gem::PubGrub understands. + # + # Package is passed to this method but for many implementations is not + # needed. + # + # Returns: either a Gem::PubGrub::VersionRange, Gem::PubGrub::VersionUnion, or a + # Gem::PubGrub::VersionConstraint + def parse_dependency(package, dependency) + raise NotImplementedError + end + + # Override me! + # + # If not overridden, this will call dependencies_for with the root package. + # + # Returns: Hash in the form of { package => requirement, ... } (see dependencies_for) + def root_dependencies + dependencies_for(@root_package, @root_version) + end + + def initialize + @root_package = Package.root + @root_version = Package.root_version + + @sorted_versions = Hash.new do |h,k| + if k == @root_package + h[k] = [@root_version] + else + h[k] = all_versions_for(k).sort + end + end + + @cached_dependencies = Hash.new do |packages, package| + if package == @root_package + packages[package] = { + @root_version => root_dependencies + } + else + packages[package] = Hash.new do |versions, version| + versions[version] = dependencies_for(package, version) + end + end + end + end + + def versions_for(package, range=VersionRange.any) + range.select_versions(@sorted_versions[package]) + end + + def no_versions_incompatibility_for(_package, unsatisfied_term) + cause = Incompatibility::NoVersions.new(unsatisfied_term) + + Incompatibility.new([unsatisfied_term], cause: cause) + end + + def incompatibilities_for(package, version) + package_deps = @cached_dependencies[package] + sorted_versions = @sorted_versions[package] + package_deps[version].map do |dep_package, dep_constraint_name| + low = high = sorted_versions.index(version) + + # find version low such that all >= low share the same dep + while low > 0 && + package_deps[sorted_versions[low - 1]][dep_package] == dep_constraint_name + low -= 1 + end + low = + if low == 0 + nil + else + sorted_versions[low] + end + + # find version high such that all < high share the same dep + while high < sorted_versions.length && + package_deps[sorted_versions[high]][dep_package] == dep_constraint_name + high += 1 + end + high = + if high == sorted_versions.length + nil + else + sorted_versions[high] + end + + range = VersionRange.new(min: low, max: high, include_min: !low.nil?) + + self_constraint = VersionConstraint.new(package, range: range) + + if !@packages.include?(dep_package) + # no such package -> this version is invalid + end + + dep_constraint = parse_dependency(dep_package, dep_constraint_name) + if !dep_constraint + # falsey indicates this dependency was invalid + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint_name) + return [Incompatibility.new([Term.new(self_constraint, true)], cause: cause)] + elsif !dep_constraint.is_a?(VersionConstraint) + # Upgrade range/union to VersionConstraint + dep_constraint = VersionConstraint.new(dep_package, range: dep_constraint) + end + + Incompatibility.new([Term.new(self_constraint, true), Term.new(dep_constraint, false)], cause: :dependency) + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb new file mode 100644 index 0000000000..d8bfde0286 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb @@ -0,0 +1,182 @@ +module Gem::PubGrub + class FailureWriter + def initialize(root) + @root = root + + # { Incompatibility => Integer } + @derivations = {} + + # [ [ String, Integer or nil ] ] + @lines = [] + + # { Incompatibility => Integer } + @line_numbers = {} + + count_derivations(root) + end + + def write + return @root.to_s unless @root.conflict? + + visit(@root) + + padding = @line_numbers.empty? ? 0 : "(#{@line_numbers.values.last}) ".length + + @lines.map do |message, number| + next "" if message.empty? + + lead = number ? "(#{number}) " : "" + lead = lead.ljust(padding) + message = message.gsub("\n", "\n" + " " * (padding + 2)) + "#{lead}#{message}" + end.join("\n") + end + + private + + def write_line(incompatibility, message, numbered:) + if numbered + number = @line_numbers.length + 1 + @line_numbers[incompatibility] = number + end + + @lines << [message, number] + end + + def visit(incompatibility, conclusion: false) + raise unless incompatibility.conflict? + + numbered = conclusion || @derivations[incompatibility] > 1; + conjunction = conclusion || incompatibility == @root ? "So," : "And" + + cause = incompatibility.cause + + if cause.conflict.conflict? && cause.other.conflict? + conflict_line = @line_numbers[cause.conflict] + other_line = @line_numbers[cause.other] + + if conflict_line && other_line + write_line( + incompatibility, + "Because #{cause.conflict} (#{conflict_line})\nand #{cause.other} (#{other_line}),\n#{incompatibility}.", + numbered: numbered + ) + elsif conflict_line || other_line + with_line = conflict_line ? cause.conflict : cause.other + without_line = conflict_line ? cause.other : cause.conflict + line = @line_numbers[with_line] + + visit(without_line); + write_line( + incompatibility, + "#{conjunction} because #{with_line} (#{line}),\n#{incompatibility}.", + numbered: numbered + ) + else + single_line_conflict = single_line?(cause.conflict.cause) + single_line_other = single_line?(cause.other.cause) + + if single_line_conflict || single_line_other + first = single_line_other ? cause.conflict : cause.other + second = single_line_other ? cause.other : cause.conflict + visit(first) + visit(second) + write_line( + incompatibility, + "Thus, #{incompatibility}.", + numbered: numbered + ) + else + visit(cause.conflict, conclusion: true) + @lines << ["", nil] + visit(cause.other) + + write_line( + incompatibility, + "#{conjunction} because #{cause.conflict} (#{@line_numbers[cause.conflict]}),\n#{incompatibility}.", + numbered: numbered + ) + end + end + elsif cause.conflict.conflict? || cause.other.conflict? + derived = cause.conflict.conflict? ? cause.conflict : cause.other + ext = cause.conflict.conflict? ? cause.other : cause.conflict + + derived_line = @line_numbers[derived] + if derived_line + write_line( + incompatibility, + "Because #{ext}\nand #{derived} (#{derived_line}),\n#{incompatibility}.", + numbered: numbered + ) + elsif collapsible?(derived) + derived_cause = derived.cause + if derived_cause.conflict.conflict? + collapsed_derived = derived_cause.conflict + collapsed_ext = derived_cause.other + else + collapsed_derived = derived_cause.other + collapsed_ext = derived_cause.conflict + end + + visit(collapsed_derived) + + write_line( + incompatibility, + "#{conjunction} because #{collapsed_ext}\nand #{ext},\n#{incompatibility}.", + numbered: numbered + ) + else + visit(derived) + write_line( + incompatibility, + "#{conjunction} because #{ext},\n#{incompatibility}.", + numbered: numbered + ) + end + else + write_line( + incompatibility, + "Because #{cause.conflict}\nand #{cause.other},\n#{incompatibility}.", + numbered: numbered + ) + end + end + + def single_line?(cause) + !cause.conflict.conflict? && !cause.other.conflict? + end + + def collapsible?(incompatibility) + return false if @derivations[incompatibility] > 1 + + cause = incompatibility.cause + # If incompatibility is derived from two derived incompatibilities, + # there are too many transitive causes to display concisely. + return false if cause.conflict.conflict? && cause.other.conflict? + + # If incompatibility is derived from two external incompatibilities, it + # tends to be confusing to collapse it. + return false unless cause.conflict.conflict? || cause.other.conflict? + + # If incompatibility's internal cause is numbered, collapsing it would + # get too noisy. + complex = cause.conflict.conflict? ? cause.conflict : cause.other + + !@line_numbers.has_key?(complex) + end + + def count_derivations(incompatibility) + if @derivations.has_key?(incompatibility) + @derivations[incompatibility] += 1 + else + @derivations[incompatibility] = 1 + if incompatibility.conflict? + cause = incompatibility.cause + count_derivations(cause.conflict) + count_derivations(cause.other) + end + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb new file mode 100644 index 0000000000..b5652b5e01 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb @@ -0,0 +1,150 @@ +module Gem::PubGrub + class Incompatibility + ConflictCause = Struct.new(:incompatibility, :satisfier) do + alias_method :conflict, :incompatibility + alias_method :other, :satisfier + end + + InvalidDependency = Struct.new(:package, :constraint) do + end + + NoVersions = Struct.new(:constraint) do + end + + attr_reader :terms, :cause + + def initialize(terms, cause:, custom_explanation: nil) + @cause = cause + @terms = cleanup_terms(terms) + @custom_explanation = custom_explanation + + if cause == :dependency && @terms.length != 2 + raise ArgumentError, "a dependency Incompatibility must have exactly two terms. Got #{@terms.inspect}" + end + end + + def hash + cause.hash ^ terms.hash + end + + def eql?(other) + cause.eql?(other.cause) && + terms.eql?(other.terms) + end + + def failure? + terms.empty? || (terms.length == 1 && Package.root?(terms[0].package) && terms[0].positive?) + end + + def conflict? + ConflictCause === cause + end + + # Returns all external incompatibilities in this incompatibility's + # derivation graph + def external_incompatibilities + if conflict? + [ + cause.conflict, + cause.other + ].flat_map(&:external_incompatibilities) + else + [this] + end + end + + def to_s + return @custom_explanation if @custom_explanation + + case cause + when :root + "(root dependency)" + when :dependency + "#{terms[0].to_s(allow_every: true)} depends on #{terms[1].invert}" + when Gem::PubGrub::Incompatibility::InvalidDependency + "#{terms[0].to_s(allow_every: true)} depends on unknown package #{cause.package}" + when Gem::PubGrub::Incompatibility::NoVersions + "no versions satisfy #{cause.constraint}" + when Gem::PubGrub::Incompatibility::ConflictCause + if failure? + "version solving has failed" + elsif terms.length == 1 + term = terms[0] + if term.positive? + if term.constraint.any? + "#{term.package} cannot be used" + else + "#{term.to_s(allow_every: true)} cannot be used" + end + else + "#{term.invert} is required" + end + else + if terms.all?(&:positive?) + if terms.length == 2 + "#{terms[0].to_s(allow_every: true)} is incompatible with #{terms[1]}" + else + "one of #{terms.map(&:to_s).join(" or ")} must be false" + end + elsif terms.all?(&:negative?) + if terms.length == 2 + "either #{terms[0].invert} or #{terms[1].invert}" + else + "one of #{terms.map(&:invert).join(" or ")} must be true"; + end + else + positive = terms.select(&:positive?) + negative = terms.select(&:negative?).map(&:invert) + + if positive.length == 1 + "#{positive[0].to_s(allow_every: true)} requires #{negative.join(" or ")}" + else + "if #{positive.join(" and ")} then #{negative.join(" or ")}" + end + end + end + else + raise "unhandled cause: #{cause.inspect}" + end + end + + def inspect + "#<#{self.class} #{to_s}>" + end + + def pretty_print(q) + q.group 2, "#<#{self.class}", ">" do + q.breakable + q.text to_s + + q.breakable + q.text " caused by " + q.pp @cause + end + end + + private + + def cleanup_terms(terms) + terms.each do |term| + raise "#{term.inspect} must be a term" unless term.is_a?(Term) + end + + if terms.length != 1 && ConflictCause === cause + terms = terms.reject do |term| + term.positive? && Package.root?(term.package) + end + end + + # Optimized simple cases + return terms if terms.length <= 1 + return terms if terms.length == 2 && terms[0].package != terms[1].package + + terms.group_by(&:package).map do |package, common_terms| + common_terms.inject do |acc, term| + acc.intersect(term) + end + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb new file mode 100644 index 0000000000..6baa908f60 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gem::PubGrub + class Package + + attr_reader :name + + def initialize(name) + @name = name + end + + def inspect + "#<#{self.class} #{name.inspect}>" + end + + def <=>(other) + name <=> other.name + end + + ROOT = Package.new(:root) + ROOT_VERSION = 0 + + def self.root + ROOT + end + + def self.root_version + ROOT_VERSION + end + + def self.root?(package) + if package.respond_to?(:root?) + package.root? + else + package == root + end + end + + def to_s + name.to_s + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb new file mode 100644 index 0000000000..f6a6ae6964 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb @@ -0,0 +1,121 @@ +require_relative 'assignment' + +module Gem::PubGrub + class PartialSolution + attr_reader :assignments, :decisions + attr_reader :attempted_solutions + + def initialize + reset! + + @attempted_solutions = 1 + @backtracking = false + end + + def decision_level + @decisions.length + end + + def relation(term) + package = term.package + return :overlap if !@terms.key?(package) + + @relation_cache[package][term] ||= + @terms[package].relation(term) + end + + def satisfies?(term) + relation(term) == :subset + end + + def derive(term, cause) + add_assignment(Assignment.new(term, cause, decision_level, assignments.length)) + end + + def satisfier(term) + assignment = + @assignments_by[term.package].bsearch do |assignment_by| + @cumulative_assignments[assignment_by].satisfies?(term) + end + + assignment || raise("#{term} unsatisfied") + end + + # A list of unsatisfied terms + def unsatisfied + @required.keys.reject do |package| + @decisions.key?(package) + end.map do |package| + @terms[package] + end + end + + def decide(package, version) + @attempted_solutions += 1 if @backtracking + @backtracking = false; + + decisions[package] = version + assignment = Assignment.decision(package, version, decision_level, assignments.length) + add_assignment(assignment) + end + + def backtrack(previous_level) + @backtracking = true + + new_assignments = assignments.select do |assignment| + assignment.decision_level <= previous_level + end + + new_decisions = Hash[decisions.first(previous_level)] + + reset! + + @decisions = new_decisions + + new_assignments.each do |assignment| + add_assignment(assignment) + end + end + + private + + def reset! + # { Array<Assignment> } + @assignments = [] + + # { Package => Array<Assignment> } + @assignments_by = Hash.new { |h,k| h[k] = [] } + @cumulative_assignments = {}.compare_by_identity + + # { Package => Package::Version } + @decisions = {} + + # { Package => Term } + @terms = {} + @relation_cache = Hash.new { |h,k| h[k] = {} } + + # { Package => Boolean } + @required = {} + end + + def add_assignment(assignment) + term = assignment.term + package = term.package + + @assignments << assignment + @assignments_by[package] << assignment + + @required[package] = true if term.positive? + + if @terms.key?(package) + old_term = @terms[package] + @terms[package] = old_term.intersect(term) + else + @terms[package] = term + end + @relation_cache[package].clear + + @cumulative_assignments[assignment] = @terms[package] + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb new file mode 100644 index 0000000000..60ca3ca2ea --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb @@ -0,0 +1,45 @@ +module Gem::PubGrub + module RubyGems + extend self + + def requirement_to_range(requirement) + ranges = requirement.requirements.map do |(op, ver)| + case op + when "~>" + name = "~> #{ver}" + bump = ver.class.new(ver.bump.to_s + ".A") + VersionRange.new(name: name, min: ver, max: bump, include_min: true) + when ">" + VersionRange.new(min: ver) + when ">=" + VersionRange.new(min: ver, include_min: true) + when "<" + VersionRange.new(max: ver) + when "<=" + VersionRange.new(max: ver, include_max: true) + when "=" + VersionRange.new(min: ver, max: ver, include_min: true, include_max: true) + when "!=" + VersionRange.new(min: ver, max: ver, include_min: true, include_max: true).invert + else + raise "bad version specifier: #{op}" + end + end + + ranges.inject(&:intersect) + end + + def requirement_to_constraint(package, requirement) + Gem::PubGrub::VersionConstraint.new(package, range: requirement_to_range(requirement)) + end + + def parse_range(dep) + requirement_to_range(Gem::Requirement.new(dep)) + end + + def parse_constraint(package, dep) + range = parse_range(dep) + Gem::PubGrub::VersionConstraint.new(package, range: range) + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb new file mode 100644 index 0000000000..c4181d2b25 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb @@ -0,0 +1,19 @@ +require_relative 'failure_writer' + +module Gem::PubGrub + class SolveFailure < StandardError + attr_reader :incompatibility + + def initialize(incompatibility) + @incompatibility = incompatibility + end + + def to_s + "Could not find compatible versions\n\n#{explanation}" + end + + def explanation + @explanation ||= FailureWriter.new(@incompatibility).write + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb new file mode 100644 index 0000000000..9e1de7d7a1 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb @@ -0,0 +1,61 @@ +require_relative 'package' +require_relative 'rubygems' +require_relative 'version_constraint' +require_relative 'incompatibility' +require_relative 'basic_package_source' + +module Gem::PubGrub + class StaticPackageSource < BasicPackageSource + class DSL + def initialize(packages, root_deps) + @packages = packages + @root_deps = root_deps + end + + def root(deps:) + @root_deps.update(deps) + end + + def add(name, version, deps: {}) + version = Gem::Version.new(version) + @packages[name] ||= {} + raise ArgumentError, "#{name} #{version} declared twice" if @packages[name].key?(version) + @packages[name][version] = clean_deps(name, version, deps) + end + + private + + # Exclude redundant self-referencing dependencies + def clean_deps(name, version, deps) + deps.reject {|dep_name, req| name == dep_name && Gem::PubGrub::RubyGems.parse_range(req).include?(version) } + end + end + + def initialize + @root_deps = {} + @packages = {} + + yield DSL.new(@packages, @root_deps) + + super() + end + + def all_versions_for(package) + @packages[package].keys + end + + def root_dependencies + @root_deps + end + + def dependencies_for(package, version) + @packages[package][version] + end + + def parse_dependency(package, dependency) + return false unless @packages.key?(package) + + Gem::PubGrub::RubyGems.parse_constraint(package, dependency) + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb new file mode 100644 index 0000000000..b9874cdece --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb @@ -0,0 +1,42 @@ +module Gem::PubGrub + class Strategy + def initialize(source) + @source = source + + @root_package = Package.root + @root_version = Package.root_version + + @version_indexes = Hash.new do |h,k| + if k == @root_package + h[k] = { @root_version => 0 } + else + h[k] = @source.all_versions_for(k).each.with_index.to_h + end + end + end + + def next_package_and_version(unsatisfied) + package, range = next_term_to_try_from(unsatisfied) + + [package, most_preferred_version_of(package, range)] + end + + private + + def most_preferred_version_of(package, range) + versions = @source.versions_for(package, range) + + indexes = @version_indexes[package] + versions.min_by { |version| indexes[version] || Float::INFINITY } + end + + def next_term_to_try_from(unsatisfied) + unsatisfied.min_by do |package, range| + matching_versions = @source.versions_for(package, range) + higher_versions = @source.versions_for(package, range.upper_invert) + + [matching_versions.count <= 1 ? 0 : 1, higher_versions.count] + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb new file mode 100644 index 0000000000..bb26bdc911 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb @@ -0,0 +1,105 @@ +module Gem::PubGrub + class Term + attr_reader :package, :constraint, :positive + + def initialize(constraint, positive) + @constraint = constraint + @package = @constraint.package + @positive = positive + end + + def to_s(allow_every: false) + if positive + @constraint.to_s(allow_every: allow_every) + else + "not #{@constraint}" + end + end + + def hash + constraint.hash ^ positive.hash + end + + def eql?(other) + positive == other.positive && + constraint.eql?(other.constraint) + end + + def invert + self.class.new(@constraint, !@positive) + end + alias_method :inverse, :invert + + def intersect(other) + raise ArgumentError, "packages must match" if package != other.package + + if positive? && other.positive? + self.class.new(constraint.intersect(other.constraint), true) + elsif negative? && other.negative? + self.class.new(constraint.union(other.constraint), false) + else + positive = positive? ? self : other + negative = negative? ? self : other + self.class.new(positive.constraint.intersect(negative.constraint.invert), true) + end + end + + def difference(other) + intersect(other.invert) + end + + def relation(other) + if positive? && other.positive? + constraint.relation(other.constraint) + elsif negative? && other.positive? + if constraint.allows_all?(other.constraint) + :disjoint + else + :overlap + end + elsif positive? && other.negative? + if !other.constraint.allows_any?(constraint) + :subset + elsif other.constraint.allows_all?(constraint) + :disjoint + else + :overlap + end + elsif negative? && other.negative? + if constraint.allows_all?(other.constraint) + :subset + else + :overlap + end + else + raise + end + end + + def normalized_constraint + @normalized_constraint ||= positive ? constraint : constraint.invert + end + + def satisfies?(other) + raise ArgumentError, "packages must match" unless package == other.package + + relation(other) == :subset + end + + def positive? + @positive + end + + def negative? + !positive? + end + + def empty? + @empty ||= normalized_constraint.empty? + end + + def inspect + "#<#{self.class} #{self}>" + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb new file mode 100644 index 0000000000..5701bf0656 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb @@ -0,0 +1,3 @@ +module Gem::PubGrub + VERSION = "0.5.0" +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb new file mode 100644 index 0000000000..ee998b3271 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb @@ -0,0 +1,129 @@ +require_relative 'version_range' + +module Gem::PubGrub + class VersionConstraint + attr_reader :package, :range + + # @param package [Gem::PubGrub::Package] + # @param range [Gem::PubGrub::VersionRange] + def initialize(package, range: nil) + @package = package + @range = range + end + + def hash + package.hash ^ range.hash + end + + def ==(other) + package == other.package && + range == other.range + end + + def eql?(other) + package.eql?(other.package) && + range.eql?(other.range) + end + + class << self + def exact(package, version) + range = VersionRange.new(min: version, max: version, include_min: true, include_max: true) + new(package, range: range) + end + + def any(package) + new(package, range: VersionRange.any) + end + + def empty(package) + new(package, range: VersionRange.empty) + end + end + + def intersect(other) + unless package == other.package + raise ArgumentError, "Can only intersect between VersionConstraint of the same package" + end + + self.class.new(package, range: range.intersect(other.range)) + end + + def union(other) + unless package == other.package + raise ArgumentError, "Can only intersect between VersionConstraint of the same package" + end + + self.class.new(package, range: range.union(other.range)) + end + + def invert + new_range = range.invert + self.class.new(package, range: new_range) + end + + def difference(other) + intersect(other.invert) + end + + def allows_all?(other) + range.allows_all?(other.range) + end + + def allows_any?(other) + range.intersects?(other.range) + end + + def subset?(other) + other.allows_all?(self) + end + + def overlap?(other) + other.allows_any?(self) + end + + def disjoint?(other) + !overlap?(other) + end + + def relation(other) + if subset?(other) + :subset + elsif overlap?(other) + :overlap + else + :disjoint + end + end + + def to_s(allow_every: false) + if Package.root?(package) + package.to_s + elsif allow_every && any? + "every version of #{package}" + else + "#{package} #{constraint_string}" + end + end + + def constraint_string + if any? + ">= 0" + else + range.to_s + end + end + + def empty? + range.empty? + end + + # Does this match every version of the package + def any? + range.any? + end + + def inspect + "#<#{self.class} #{self}>" + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb new file mode 100644 index 0000000000..fa0e2d5742 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb @@ -0,0 +1,423 @@ +# frozen_string_literal: true + +module Gem::PubGrub + class VersionRange + attr_reader :min, :max, :include_min, :include_max + + alias_method :include_min?, :include_min + alias_method :include_max?, :include_max + + class Empty < VersionRange + undef_method :min, :max + undef_method :include_min, :include_min? + undef_method :include_max, :include_max? + + def initialize + end + + def empty? + true + end + + def eql?(other) + other.empty? + end + + def hash + [].hash + end + + def intersects?(_) + false + end + + def intersect(other) + self + end + + def allows_all?(other) + other.empty? + end + + def include?(_) + false + end + + def any? + false + end + + def to_s + "(no versions)" + end + + def ==(other) + other.class == self.class + end + + def invert + VersionRange.any + end + + def select_versions(_) + [] + end + end + + EMPTY = Empty.new + Empty.singleton_class.undef_method(:new) + + def self.empty + EMPTY + end + + def self.any + new + end + + def initialize(min: nil, max: nil, include_min: false, include_max: false, name: nil) + raise ArgumentError, "Ranges without a lower bound cannot have include_min == true" if !min && include_min == true + raise ArgumentError, "Ranges without an upper bound cannot have include_max == true" if !max && include_max == true + + @min = min + @max = max + @include_min = include_min + @include_max = include_max + @name = name + end + + def hash + @hash ||= min.hash ^ max.hash ^ include_min.hash ^ include_max.hash + end + + def eql?(other) + if other.is_a?(VersionRange) + !other.empty? && + min.eql?(other.min) && + max.eql?(other.max) && + include_min.eql?(other.include_min) && + include_max.eql?(other.include_max) + else + ranges.eql?(other.ranges) + end + end + + def ranges + [self] + end + + def include?(version) + compare_version(version) == 0 + end + + # Partitions passed versions into [lower, within, higher] + # + # versions must be sorted + def partition_versions(versions) + min_index = + if !min || versions.empty? + 0 + elsif include_min? + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] >= min } + else + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] > min } + end + + lower = versions.slice(0, min_index) + versions = versions.slice(min_index, versions.size) + + max_index = + if !max || versions.empty? + versions.size + elsif include_max? + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] > max } + else + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] >= max } + end + + [ + lower, + versions.slice(0, max_index), + versions.slice(max_index, versions.size) + ] + end + + # Returns versions which are included by this range. + # + # versions must be sorted + def select_versions(versions) + return versions if any? + + partition_versions(versions)[1] + end + + def compare_version(version) + if min + case version <=> min + when -1 + return -1 + when 0 + return -1 if !include_min + when 1 + end + end + + if max + case version <=> max + when -1 + when 0 + return 1 if !include_max + when 1 + return 1 + end + end + + 0 + end + + def strictly_lower?(other) + return false if !max || !other.min + + case max <=> other.min + when 0 + !include_max || !other.include_min + when -1 + true + when 1 + false + end + end + + def strictly_higher?(other) + other.strictly_lower?(self) + end + + def intersects?(other) + return false if other.empty? + return other.intersects?(self) if other.is_a?(VersionUnion) + !strictly_lower?(other) && !strictly_higher?(other) + end + alias_method :allows_any?, :intersects? + + def intersect(other) + return other if other.empty? + return other.intersect(self) if other.is_a?(VersionUnion) + + min_range = + if !min + other + elsif !other.min + self + else + case min <=> other.min + when 0 + include_min ? other : self + when -1 + other + when 1 + self + end + end + + max_range = + if !max + other + elsif !other.max + self + else + case max <=> other.max + when 0 + include_max ? other : self + when -1 + self + when 1 + other + end + end + + if !min_range.equal?(max_range) && min_range.min && max_range.max + case min_range.min <=> max_range.max + when -1 + when 0 + if !min_range.include_min || !max_range.include_max + return EMPTY + end + when 1 + return EMPTY + end + end + + VersionRange.new( + min: min_range.min, + include_min: min_range.include_min, + max: max_range.max, + include_max: max_range.include_max + ) + end + + # The span covered by two ranges + # + # If self and other are contiguous, this builds a union of the two ranges. + # (if they aren't you are probably calling the wrong method) + def span(other) + return self if other.empty? + + min_range = + if !min + self + elsif !other.min + other + else + case min <=> other.min + when 0 + include_min ? self : other + when -1 + self + when 1 + other + end + end + + max_range = + if !max + self + elsif !other.max + other + else + case max <=> other.max + when 0 + include_max ? self : other + when -1 + other + when 1 + self + end + end + + VersionRange.new( + min: min_range.min, + include_min: min_range.include_min, + max: max_range.max, + include_max: max_range.include_max + ) + end + + def union(other) + return other.union(self) if other.is_a?(VersionUnion) + + if contiguous_to?(other) + span(other) + else + VersionUnion.union([self, other]) + end + end + + def contiguous_to?(other) + return false if other.empty? + return true if any? + + intersects?(other) || contiguous_below?(other) || contiguous_above?(other) + end + + def contiguous_below?(other) + return false if !max || !other.min + + max == other.min && (include_max || other.include_min) + end + + def contiguous_above?(other) + other.contiguous_below?(self) + end + + def allows_all?(other) + return true if other.empty? + + if other.is_a?(VersionUnion) + return VersionUnion.new([self]).allows_all?(other) + end + + return false if max && !other.max + return false if min && !other.min + + if min + case min <=> other.min + when -1 + when 0 + return false if !include_min && other.include_min + when 1 + return false + end + end + + if max + case max <=> other.max + when -1 + return false + when 0 + return false if !include_max && other.include_max + when 1 + end + end + + true + end + + def any? + !min && !max + end + + def empty? + false + end + + def to_s + @name ||= constraints.join(", ") + end + + def inspect + "#<#{self.class} #{to_s}>" + end + + def upper_invert + return self.class.empty unless max + + VersionRange.new(min: max, include_min: !include_max) + end + + def invert + return self.class.empty if any? + + low = -> { VersionRange.new(max: min, include_max: !include_min) } + high = -> { VersionRange.new(min: max, include_min: !include_max) } + + if !min + high.call + elsif !max + low.call + else + low.call.union(high.call) + end + end + + def ==(other) + self.class == other.class && + min == other.min && + max == other.max && + include_min == other.include_min && + include_max == other.include_max + end + + private + + def constraints + return ["any"] if any? + return ["= #{min}"] if min.to_s == max.to_s + + c = [] + c << "#{include_min ? ">=" : ">"} #{min}" if min + c << "#{include_max ? "<=" : "<"} #{max}" if max + c + end + + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb new file mode 100644 index 0000000000..3341d8fe3b --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb @@ -0,0 +1,236 @@ +require_relative 'partial_solution' +require_relative 'term' +require_relative 'incompatibility' +require_relative 'solve_failure' +require_relative 'strategy' + +module Gem::PubGrub + class VersionSolver + attr_reader :logger + attr_reader :source + attr_reader :solution + attr_reader :strategy + + def initialize(source:, root: Package.root, strategy: Strategy.new(source), logger: Gem::PubGrub.logger) + @logger = logger + + @source = source + @strategy = strategy + + # { package => [incompatibility, ...]} + @incompatibilities = Hash.new do |h, k| + h[k] = [] + end + + @seen_incompatibilities = {} + + @solution = PartialSolution.new + + add_incompatibility Incompatibility.new([ + Term.new(VersionConstraint.any(root), false) + ], cause: :root) + + propagate(root) + end + + def solved? + solution.unsatisfied.empty? + end + + # Returns true if there is more work to be done, false otherwise + def work + unsatisfied_terms = solution.unsatisfied + if unsatisfied_terms.empty? + logger.info { "Solution found after #{solution.attempted_solutions} attempts:" } + solution.decisions.each do |package, version| + next if Package.root?(package) + logger.info { "* #{package} #{version}" } + end + + return false + end + + next_package = choose_package_version_from(unsatisfied_terms) + propagate(next_package) + + true + end + + def solve + while work; end + + solution.decisions + end + + alias_method :result, :solve + + private + + def propagate(initial_package) + changed = [initial_package] + while package = changed.shift + @incompatibilities[package].reverse_each do |incompatibility| + result = propagate_incompatibility(incompatibility) + if result == :conflict + root_cause = resolve_conflict(incompatibility) + changed.clear + changed << propagate_incompatibility(root_cause) + elsif result # should be a Package + changed << result + end + end + changed.uniq! + end + end + + def propagate_incompatibility(incompatibility) + unsatisfied = nil + incompatibility.terms.each do |term| + relation = solution.relation(term) + if relation == :disjoint + return nil + elsif relation == :overlap + # If more than one term is inconclusive, we can't deduce anything + return nil if unsatisfied + unsatisfied = term + end + end + + if !unsatisfied + return :conflict + end + + logger.debug { "derived: #{unsatisfied.invert}" } + + solution.derive(unsatisfied.invert, incompatibility) + + unsatisfied.package + end + + def choose_package_version_from(unsatisfied_terms) + remaining = unsatisfied_terms.map { |t| [t.package, t.constraint.range] }.to_h + + package, version = strategy.next_package_and_version(remaining) + + logger.debug { "attempting #{package} #{version}" } + + if version.nil? + unsatisfied_term = unsatisfied_terms.find { |t| t.package == package } + add_incompatibility source.no_versions_incompatibility_for(package, unsatisfied_term) + return package + end + + conflict = false + + source.incompatibilities_for(package, version).each do |incompatibility| + if @seen_incompatibilities.include?(incompatibility) + logger.debug { "knew: #{incompatibility}" } + next + end + @seen_incompatibilities[incompatibility] = true + + add_incompatibility incompatibility + + conflict ||= incompatibility.terms.all? do |term| + term.package == package || solution.satisfies?(term) + end + end + + unless conflict + logger.info { "selected #{package} #{version}" } + + solution.decide(package, version) + else + logger.info { "conflict: #{conflict.inspect}" } + end + + package + end + + def resolve_conflict(incompatibility) + logger.info { "conflict: #{incompatibility}" } + + new_incompatibility = nil + + while !incompatibility.failure? + most_recent_term = nil + most_recent_satisfier = nil + difference = nil + + previous_level = 1 + + incompatibility.terms.each do |term| + satisfier = solution.satisfier(term) + + if most_recent_satisfier.nil? + most_recent_term = term + most_recent_satisfier = satisfier + elsif most_recent_satisfier.index < satisfier.index + previous_level = [previous_level, most_recent_satisfier.decision_level].max + most_recent_term = term + most_recent_satisfier = satisfier + difference = nil + else + previous_level = [previous_level, satisfier.decision_level].max + end + + if most_recent_term == term + difference = most_recent_satisfier.term.difference(most_recent_term) + if difference.empty? + difference = nil + else + difference_satisfier = solution.satisfier(difference.inverse) + previous_level = [previous_level, difference_satisfier.decision_level].max + end + end + end + + if previous_level < most_recent_satisfier.decision_level || + most_recent_satisfier.decision? + + logger.info { "backtracking to #{previous_level}" } + solution.backtrack(previous_level) + + if new_incompatibility + add_incompatibility(new_incompatibility) + end + + return incompatibility + end + + new_terms = [] + new_terms += incompatibility.terms - [most_recent_term] + new_terms += most_recent_satisfier.cause.terms.reject { |term| + term.package == most_recent_satisfier.term.package + } + if difference + new_terms << difference.invert + end + + new_incompatibility = Incompatibility.new(new_terms, cause: Incompatibility::ConflictCause.new(incompatibility, most_recent_satisfier.cause)) + + if incompatibility.to_s == new_incompatibility.to_s + logger.info { "!! failed to resolve conflicts, this shouldn't have happened" } + break + end + + incompatibility = new_incompatibility + + partially = difference ? " partially" : "" + logger.info { "! #{most_recent_term} is#{partially} satisfied by #{most_recent_satisfier.term}" } + logger.info { "! which is caused by #{most_recent_satisfier.cause}" } + logger.info { "! thus #{incompatibility}" } + end + + raise SolveFailure.new(incompatibility) + end + + def add_incompatibility(incompatibility) + logger.debug { "fact: #{incompatibility}" } + incompatibility.terms.each do |term| + package = term.package + @incompatibilities[package] << incompatibility + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb new file mode 100644 index 0000000000..4166318a98 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module Gem::PubGrub + class VersionUnion + attr_reader :ranges + + def self.normalize_ranges(ranges) + ranges = ranges.flat_map do |range| + range.ranges + end + + ranges.reject!(&:empty?) + + return [] if ranges.empty? + + mins, ranges = ranges.partition { |r| !r.min } + original_ranges = mins + ranges.sort_by { |r| [r.min, r.include_min ? 0 : 1] } + ranges = [original_ranges.shift] + original_ranges.each do |range| + if ranges.last.contiguous_to?(range) + ranges << ranges.pop.span(range) + else + ranges << range + end + end + + ranges + end + + def self.union(ranges, normalize: true) + ranges = normalize_ranges(ranges) if normalize + + if ranges.size == 0 + VersionRange.empty + elsif ranges.size == 1 + ranges[0] + else + new(ranges) + end + end + + def initialize(ranges) + raise ArgumentError unless ranges.all? { |r| r.instance_of?(VersionRange) } + @ranges = ranges + end + + def hash + ranges.hash + end + + def eql?(other) + ranges.eql?(other.ranges) + end + + def include?(version) + !!ranges.bsearch {|r| r.compare_version(version) } + end + + def select_versions(all_versions) + versions = [] + ranges.inject(all_versions) do |acc, range| + _, matching, higher = range.partition_versions(acc) + versions.concat matching + higher + end + versions + end + + def intersects?(other) + my_ranges = ranges.dup + other_ranges = other.ranges.dup + + my_range = my_ranges.shift + other_range = other_ranges.shift + while my_range && other_range + if my_range.intersects?(other_range) + return true + end + + if !my_range.max || other_range.empty? || (other_range.max && other_range.max < my_range.max) + other_range = other_ranges.shift + else + my_range = my_ranges.shift + end + end + end + alias_method :allows_any?, :intersects? + + def allows_all?(other) + my_ranges = ranges.dup + + my_range = my_ranges.shift + + other.ranges.all? do |other_range| + while my_range + break if my_range.allows_all?(other_range) + my_range = my_ranges.shift + end + + !!my_range + end + end + + def empty? + false + end + + def any? + false + end + + def intersect(other) + my_ranges = ranges.dup + other_ranges = other.ranges.dup + new_ranges = [] + + my_range = my_ranges.shift + other_range = other_ranges.shift + while my_range && other_range + new_ranges << my_range.intersect(other_range) + + if !my_range.max || other_range.empty? || (other_range.max && other_range.max < my_range.max) + other_range = other_ranges.shift + else + my_range = my_ranges.shift + end + end + new_ranges.reject!(&:empty?) + VersionUnion.union(new_ranges, normalize: false) + end + + def upper_invert + ranges.last.upper_invert + end + + def invert + ranges.map(&:invert).inject(:intersect) + end + + def union(other) + VersionUnion.union([self, other]) + end + + def to_s + output = [] + + ranges = self.ranges.dup + while !ranges.empty? + ne = [] + range = ranges.shift + while !ranges.empty? && ranges[0].min.to_s == range.max.to_s + ne << range.max + range = range.span(ranges.shift) + end + + ne.map! {|x| "!= #{x}" } + if ne.empty? + output << range.to_s + elsif range.any? + output << ne.join(', ') + else + output << "#{range}, #{ne.join(', ')}" + end + end + + output.join(" OR ") + end + + def inspect + "#<#{self.class} #{to_s}>" + end + + def ==(other) + self.class == other.class && + self.ranges == other.ranges + end + end +end diff --git a/lib/rubygems/vendor/resolv/lib/resolv.rb b/lib/rubygems/vendor/resolv/lib/resolv.rb new file mode 100644 index 0000000000..4f48e0642b --- /dev/null +++ b/lib/rubygems/vendor/resolv/lib/resolv.rb @@ -0,0 +1,3499 @@ +# frozen_string_literal: true + +require 'socket' +require_relative '../../../vendored_timeout' +require 'io/wait' +require_relative '../../../vendored_securerandom' +require 'rbconfig' + +# Gem::Resolv is a thread-aware DNS resolver library written in Ruby. Gem::Resolv can +# handle multiple DNS requests concurrently without blocking the entire Ruby +# interpreter. +# +# See also resolv-replace.rb to replace the libc resolver with Gem::Resolv. +# +# Gem::Resolv can look up various DNS resources using the DNS module directly. +# +# Examples: +# +# p Gem::Resolv.getaddress "www.ruby-lang.org" +# p Gem::Resolv.getname "210.251.121.214" +# +# Gem::Resolv::DNS.open do |dns| +# ress = dns.getresources "www.ruby-lang.org", Gem::Resolv::DNS::Resource::IN::A +# p ress.map(&:address) +# ress = dns.getresources "ruby-lang.org", Gem::Resolv::DNS::Resource::IN::MX +# p ress.map { |r| [r.exchange.to_s, r.preference] } +# end +# +# +# == Bugs +# +# * NIS is not supported. +# * /etc/nsswitch.conf is not supported. + +class Gem::Resolv + + # The version string + VERSION = "0.7.0" + + ## + # Looks up the first IP address for +name+. + + def self.getaddress(name) + DefaultResolver.getaddress(name) + end + + ## + # Looks up all IP address for +name+. + + def self.getaddresses(name) + DefaultResolver.getaddresses(name) + end + + ## + # Iterates over all IP addresses for +name+. + + def self.each_address(name, &block) + DefaultResolver.each_address(name, &block) + end + + ## + # Looks up the hostname of +address+. + + def self.getname(address) + DefaultResolver.getname(address) + end + + ## + # Looks up all hostnames for +address+. + + def self.getnames(address) + DefaultResolver.getnames(address) + end + + ## + # Iterates over all hostnames for +address+. + + def self.each_name(address, &proc) + DefaultResolver.each_name(address, &proc) + end + + ## + # Creates a new Gem::Resolv using +resolvers+. + # + # If +resolvers+ is not given, a hash, or +nil+, uses a Hosts resolver and + # and a DNS resolver. If +resolvers+ is a hash, uses the hash as + # configuration for the DNS resolver. + + def initialize(resolvers=(arg_not_set = true; nil), use_ipv6: (keyword_not_set = true; nil)) + if !keyword_not_set && !arg_not_set + warn "Support for separate use_ipv6 keyword is deprecated, as it is ignored if an argument is provided. Do not provide a positional argument if using the use_ipv6 keyword argument.", uplevel: 1 + end + + @resolvers = case resolvers + when Hash, nil + [Hosts.new, DNS.new(DNS::Config.default_config_hash.merge(resolvers || {}))] + else + resolvers + end + end + + ## + # Looks up the first IP address for +name+. + + def getaddress(name) + each_address(name) {|address| return address} + raise ResolvError.new("no address for #{name}") + end + + ## + # Looks up all IP address for +name+. + + def getaddresses(name) + ret = [] + each_address(name) {|address| ret << address} + return ret + end + + ## + # Iterates over all IP addresses for +name+. + + def each_address(name) + if AddressRegex =~ name + yield name + return + end + yielded = false + @resolvers.each {|r| + r.each_address(name) {|address| + yield address.to_s + yielded = true + } + return if yielded + } + end + + ## + # Looks up the hostname of +address+. + + def getname(address) + each_name(address) {|name| return name} + raise ResolvError.new("no name for #{address}") + end + + ## + # Looks up all hostnames for +address+. + + def getnames(address) + ret = [] + each_name(address) {|name| ret << name} + return ret + end + + ## + # Iterates over all hostnames for +address+. + + def each_name(address) + yielded = false + @resolvers.each {|r| + r.each_name(address) {|name| + yield name.to_s + yielded = true + } + return if yielded + } + end + + ## + # Indicates a failure to resolve a name or address. + + class ResolvError < StandardError; end + + ## + # Indicates a timeout resolving a name or address. + + class ResolvTimeout < Gem::Timeout::Error; end + + ## + # Gem::Resolv::Hosts is a hostname resolver that uses the system hosts file. + + class Hosts + if /mswin|cygwin|mingw|bccwin/ =~ RUBY_PLATFORM || ::RbConfig::CONFIG['host_os'] =~ /mswin/ + begin + require 'win32/resolv' unless defined?(Win32::Resolv) + hosts = Win32::Resolv.get_hosts_path || IO::NULL + rescue LoadError + end + end + # The default file name for host names + DefaultFileName = hosts || '/etc/hosts' + + ## + # Creates a new Gem::Resolv::Hosts, using +filename+ for its data source. + + def initialize(filename = DefaultFileName) + @filename = filename + @mutex = Thread::Mutex.new + @initialized = nil + end + + def lazy_initialize # :nodoc: + @mutex.synchronize { + unless @initialized + @name2addr = {} + @addr2name = {} + File.open(@filename, 'rb') {|f| + f.each {|line| + line.sub!(/#.*/, '') + addr, *hostnames = line.split(/\s+/) + next unless addr + (@addr2name[addr] ||= []).concat(hostnames) + hostnames.each {|hostname| (@name2addr[hostname] ||= []) << addr} + } + } + @name2addr.each {|name, arr| arr.reverse!} + @initialized = true + end + } + self + end + + ## + # Gets the IP address of +name+ from the hosts file. + + def getaddress(name) + each_address(name) {|address| return address} + raise ResolvError.new("#{@filename} has no name: #{name}") + end + + ## + # Gets all IP addresses for +name+ from the hosts file. + + def getaddresses(name) + ret = [] + each_address(name) {|address| ret << address} + return ret + end + + ## + # Iterates over all IP addresses for +name+ retrieved from the hosts file. + + def each_address(name, &proc) + lazy_initialize + @name2addr[name]&.each(&proc) + end + + ## + # Gets the hostname of +address+ from the hosts file. + + def getname(address) + each_name(address) {|name| return name} + raise ResolvError.new("#{@filename} has no address: #{address}") + end + + ## + # Gets all hostnames for +address+ from the hosts file. + + def getnames(address) + ret = [] + each_name(address) {|name| ret << name} + return ret + end + + ## + # Iterates over all hostnames for +address+ retrieved from the hosts file. + + def each_name(address, &proc) + lazy_initialize + @addr2name[address]&.each(&proc) + end + end + + ## + # Gem::Resolv::DNS is a DNS stub resolver. + # + # Information taken from the following places: + # + # * STD0013 + # * RFC 1035 + # * ftp://ftp.isi.edu/in-notes/iana/assignments/dns-parameters + # * etc. + + class DNS + + ## + # Default DNS Port + + Port = 53 + + ## + # Default DNS UDP packet size + + UDPSize = 512 + + ## + # Creates a new DNS resolver. See Gem::Resolv::DNS.new for argument details. + # + # Yields the created DNS resolver to the block, if given, otherwise + # returns it. + + def self.open(*args) + dns = new(*args) + return dns unless block_given? + begin + yield dns + ensure + dns.close + end + end + + ## + # Creates a new DNS resolver. + # + # +config_info+ can be: + # + # nil:: Uses /etc/resolv.conf. + # String:: Path to a file using /etc/resolv.conf's format. + # Hash:: Must contain :nameserver, :search and :ndots keys. + # :nameserver_port can be used to specify port number of nameserver address. + # :raise_timeout_errors can be used to raise timeout errors + # as exceptions instead of treating the same as an NXDOMAIN response. + # + # The value of :nameserver should be an address string or + # an array of address strings. + # - :nameserver => '8.8.8.8' + # - :nameserver => ['8.8.8.8', '8.8.4.4'] + # + # The value of :nameserver_port should be an array of + # pair of nameserver address and port number. + # - :nameserver_port => [['8.8.8.8', 53], ['8.8.4.4', 53]] + # + # Example: + # + # Gem::Resolv::DNS.new(:nameserver => ['210.251.121.21'], + # :search => ['ruby-lang.org'], + # :ndots => 1) + + def initialize(config_info=nil) + @mutex = Thread::Mutex.new + @config = Config.new(config_info) + @initialized = nil + end + + # Sets the resolver timeouts. This may be a single positive number + # or an array of positive numbers representing timeouts in seconds. + # If an array is specified, a DNS request will retry and wait for + # each successive interval in the array until a successful response + # is received. Specifying +nil+ reverts to the default timeouts: + # [ 5, second = 5 * 2 / nameserver_count, 2 * second, 4 * second ] + # + # Example: + # + # dns.timeouts = 3 + # + def timeouts=(values) + @config.timeouts = values + end + + def lazy_initialize # :nodoc: + @mutex.synchronize { + unless @initialized + @config.lazy_initialize + @initialized = true + end + } + self + end + + ## + # Closes the DNS resolver. + + def close + @mutex.synchronize { + if @initialized + @initialized = false + end + } + end + + ## + # Gets the IP address of +name+ from the DNS resolver. + # + # +name+ can be a Gem::Resolv::DNS::Name or a String. Retrieved address will + # be a Gem::Resolv::IPv4 or Gem::Resolv::IPv6 + + def getaddress(name) + each_address(name) {|address| return address} + raise ResolvError.new("DNS result has no information for #{name}") + end + + ## + # Gets all IP addresses for +name+ from the DNS resolver. + # + # +name+ can be a Gem::Resolv::DNS::Name or a String. Retrieved addresses will + # be a Gem::Resolv::IPv4 or Gem::Resolv::IPv6 + + def getaddresses(name) + ret = [] + each_address(name) {|address| ret << address} + return ret + end + + ## + # Iterates over all IP addresses for +name+ retrieved from the DNS + # resolver. + # + # +name+ can be a Gem::Resolv::DNS::Name or a String. Retrieved addresses will + # be a Gem::Resolv::IPv4 or Gem::Resolv::IPv6 + + def each_address(name) + if use_ipv6? + each_resource(name, Resource::IN::AAAA) {|resource| yield resource.address} + end + each_resource(name, Resource::IN::A) {|resource| yield resource.address} + end + + def use_ipv6? # :nodoc: + @config.lazy_initialize unless @config.instance_variable_get(:@initialized) + + use_ipv6 = @config.use_ipv6? + unless use_ipv6.nil? + return use_ipv6 + end + + begin + list = Socket.ip_address_list + rescue NotImplementedError + return true + end + list.any? {|a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? } + end + private :use_ipv6? + + ## + # Gets the hostname for +address+ from the DNS resolver. + # + # +address+ must be a Gem::Resolv::IPv4, Gem::Resolv::IPv6 or a String. Retrieved + # name will be a Gem::Resolv::DNS::Name. + + def getname(address) + each_name(address) {|name| return name} + raise ResolvError.new("DNS result has no information for #{address}") + end + + ## + # Gets all hostnames for +address+ from the DNS resolver. + # + # +address+ must be a Gem::Resolv::IPv4, Gem::Resolv::IPv6 or a String. Retrieved + # names will be Gem::Resolv::DNS::Name instances. + + def getnames(address) + ret = [] + each_name(address) {|name| ret << name} + return ret + end + + ## + # Iterates over all hostnames for +address+ retrieved from the DNS + # resolver. + # + # +address+ must be a Gem::Resolv::IPv4, Gem::Resolv::IPv6 or a String. Retrieved + # names will be Gem::Resolv::DNS::Name instances. + + def each_name(address) + case address + when Name + ptr = address + when IPv4, IPv6 + ptr = address.to_name + when IPv4::Regex + ptr = IPv4.create(address).to_name + when IPv6::Regex + ptr = IPv6.create(address).to_name + else + raise ResolvError.new("cannot interpret as address: #{address}") + end + each_resource(ptr, Resource::IN::PTR) {|resource| yield resource.name} + end + + ## + # Look up the +typeclass+ DNS resource of +name+. + # + # +name+ must be a Gem::Resolv::DNS::Name or a String. + # + # +typeclass+ should be one of the following: + # + # * Gem::Resolv::DNS::Resource::IN::A + # * Gem::Resolv::DNS::Resource::IN::AAAA + # * Gem::Resolv::DNS::Resource::IN::ANY + # * Gem::Resolv::DNS::Resource::IN::CNAME + # * Gem::Resolv::DNS::Resource::IN::HINFO + # * Gem::Resolv::DNS::Resource::IN::MINFO + # * Gem::Resolv::DNS::Resource::IN::MX + # * Gem::Resolv::DNS::Resource::IN::NS + # * Gem::Resolv::DNS::Resource::IN::PTR + # * Gem::Resolv::DNS::Resource::IN::SOA + # * Gem::Resolv::DNS::Resource::IN::TXT + # * Gem::Resolv::DNS::Resource::IN::WKS + # + # Returned resource is represented as a Gem::Resolv::DNS::Resource instance, + # i.e. Gem::Resolv::DNS::Resource::IN::A. + + def getresource(name, typeclass) + each_resource(name, typeclass) {|resource| return resource} + raise ResolvError.new("DNS result has no information for #{name}") + end + + ## + # Looks up all +typeclass+ DNS resources for +name+. See #getresource for + # argument details. + + def getresources(name, typeclass) + ret = [] + each_resource(name, typeclass) {|resource| ret << resource} + return ret + end + + ## + # Iterates over all +typeclass+ DNS resources for +name+. See + # #getresource for argument details. + + def each_resource(name, typeclass, &proc) + fetch_resource(name, typeclass) {|reply, reply_name| + extract_resources(reply, reply_name, typeclass, &proc) + } + end + + # :stopdoc: + + def fetch_resource(name, typeclass) + lazy_initialize + truncated = {} + requesters = {} + udp_requester = begin + make_udp_requester + rescue Errno::EACCES + # fall back to TCP + end + senders = {} + + begin + @config.resolv(name) do |candidate, tout, nameserver, port| + msg = Message.new + msg.rd = 1 + msg.add_question(candidate, typeclass) + + requester = requesters.fetch([nameserver, port]) do + if !truncated[candidate] && udp_requester + udp_requester + else + requesters[[nameserver, port]] = make_tcp_requester(nameserver, port) + end + end + + unless sender = senders[[candidate, requester, nameserver, port]] + sender = requester.sender(msg, candidate, nameserver, port) + next if !sender + senders[[candidate, requester, nameserver, port]] = sender + end + reply, reply_name = requester.request(sender, tout) + case reply.rcode + when RCode::NoError + if reply.tc == 1 and not Requester::TCP === requester + # Retry via TCP: + truncated[candidate] = true + redo + else + yield(reply, reply_name) + end + return + when RCode::NXDomain + raise Config::NXDomain.new(reply_name.to_s) + else + raise Config::OtherResolvError.new(reply_name.to_s) + end + end + ensure + udp_requester&.close + requesters.each_value { |requester| requester&.close } + end + end + + def make_udp_requester # :nodoc: + nameserver_port = @config.nameserver_port + if nameserver_port.length == 1 + Requester::ConnectedUDP.new(*nameserver_port[0]) + else + Requester::UnconnectedUDP.new(*nameserver_port) + end + end + + def make_tcp_requester(host, port) # :nodoc: + return Requester::TCP.new(host, port) + rescue Errno::ECONNREFUSED + # Treat a refused TCP connection attempt to a nameserver like a timeout, + # as Gem::Resolv::DNS::Config#resolv considers ResolvTimeout exceptions as a + # hint to try the next nameserver: + raise ResolvTimeout + end + + def extract_resources(msg, name, typeclass) # :nodoc: + if typeclass < Resource::ANY + n0 = Name.create(name) + msg.each_resource {|n, ttl, data| + yield data if n0 == n + } + end + yielded = false + n0 = Name.create(name) + msg.each_resource {|n, ttl, data| + if n0 == n + case data + when typeclass + yield data + yielded = true + when Resource::CNAME + n0 = data.name + end + end + } + return if yielded + msg.each_resource {|n, ttl, data| + if n0 == n + case data + when typeclass + yield data + end + end + } + end + + def self.random(arg) # :nodoc: + begin + Gem::SecureRandom.random_number(arg) + rescue NotImplementedError + rand(arg) + end + end + + RequestID = {} # :nodoc: + RequestIDMutex = Thread::Mutex.new # :nodoc: + + def self.allocate_request_id(host, port) # :nodoc: + id = nil + RequestIDMutex.synchronize { + h = (RequestID[[host, port]] ||= {}) + begin + id = random(0x0000..0xffff) + end while h[id] + h[id] = true + } + id + end + + def self.free_request_id(host, port, id) # :nodoc: + RequestIDMutex.synchronize { + key = [host, port] + if h = RequestID[key] + h.delete id + if h.empty? + RequestID.delete key + end + end + } + end + + case RUBY_PLATFORM + when *[ + # https://www.rfc-editor.org/rfc/rfc6056.txt + # Appendix A. Survey of the Algorithms in Use by Some Popular Implementations + /freebsd/, /linux/, /netbsd/, /openbsd/, /solaris/, + /darwin/, # the same as FreeBSD + ] then + def self.bind_random_port(udpsock, bind_host="0.0.0.0") # :nodoc: + udpsock.bind(bind_host, 0) + end + else + # Sequential port assignment + def self.bind_random_port(udpsock, bind_host="0.0.0.0") # :nodoc: + # Ephemeral port number range recommended by RFC 6056 + port = random(1024..65535) + udpsock.bind(bind_host, port) + rescue Errno::EADDRINUSE, # POSIX + Errno::EACCES, # SunOS: See PRIV_SYS_NFS in privileges(5) + Errno::EPERM # FreeBSD: security.mac.portacl.port_high is configurable. See mac_portacl(4). + retry + end + end + + class Requester # :nodoc: + def initialize + @senders = {} + @socks = nil + end + + def request(sender, tout) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timelimit = start + tout + begin + sender.send + rescue Errno::EHOSTUNREACH, # multi-homed IPv6 may generate this + Errno::ENETUNREACH + raise ResolvTimeout + end + while true + before_select = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout = timelimit - before_select + if timeout <= 0 + raise ResolvTimeout + end + if @socks.size == 1 + select_result = @socks[0].wait_readable(timeout) ? [ @socks ] : nil + else + select_result = IO.select(@socks, nil, nil, timeout) + end + if !select_result + after_select = Process.clock_gettime(Process::CLOCK_MONOTONIC) + next if after_select < timelimit + raise ResolvTimeout + end + begin + reply, from = recv_reply(select_result[0]) + rescue Errno::ECONNREFUSED, # GNU/Linux, FreeBSD + Errno::ECONNRESET # Windows + # No name server running on the server? + # Don't wait anymore. + raise ResolvTimeout + end + begin + msg = Message.decode(reply) + rescue DecodeError + next # broken DNS message ignored + end + if sender == sender_for(from, msg) + break + else + # unexpected DNS message ignored + end + end + return msg, sender.data + end + + def sender_for(addr, msg) + @senders[[addr,msg.id]] + end + + def close + socks = @socks + @socks = nil + socks&.each(&:close) + end + + class Sender # :nodoc: + def initialize(msg, data, sock) + @msg = msg + @data = data + @sock = sock + end + end + + class UnconnectedUDP < Requester # :nodoc: + def initialize(*nameserver_port) + super() + @nameserver_port = nameserver_port + @initialized = false + @mutex = Thread::Mutex.new + end + + def lazy_initialize + @mutex.synchronize { + next if @initialized + @initialized = true + @socks_hash = {} + @socks = [] + @nameserver_port.each {|host, port| + if host.index(':') + bind_host = "::" + af = Socket::AF_INET6 + else + bind_host = "0.0.0.0" + af = Socket::AF_INET + end + next if @socks_hash[bind_host] + begin + sock = UDPSocket.new(af) + rescue Errno::EAFNOSUPPORT, Errno::EPROTONOSUPPORT + next # The kernel doesn't support the address family. + end + @socks << sock + @socks_hash[bind_host] = sock + sock.do_not_reverse_lookup = true + DNS.bind_random_port(sock, bind_host) + } + } + self + end + + def recv_reply(readable_socks) + lazy_initialize + reply, from = readable_socks[0].recvfrom(UDPSize) + return reply, [from[3],from[1]] + end + + def sender(msg, data, host, port=Port) + host = Addrinfo.ip(host).ip_address + lazy_initialize + sock = @socks_hash[host.index(':') ? "::" : "0.0.0.0"] + return nil if !sock + service = [host, port] + id = DNS.allocate_request_id(host, port) + request = msg.encode + request[0,2] = [id].pack('n') + return @senders[[service, id]] = + Sender.new(request, data, sock, host, port) + end + + def close + @mutex.synchronize { + if @initialized + super + @senders.each_key {|service, id| + DNS.free_request_id(service[0], service[1], id) + } + @initialized = false + end + } + end + + class Sender < Requester::Sender # :nodoc: + def initialize(msg, data, sock, host, port) + super(msg, data, sock) + @host = host + @port = port + end + attr_reader :data + + def send + raise "@sock is nil." if @sock.nil? + @sock.send(@msg, 0, @host, @port) + end + end + end + + class ConnectedUDP < Requester # :nodoc: + def initialize(host, port=Port) + super() + @host = host + @port = port + @mutex = Thread::Mutex.new + @initialized = false + end + + def lazy_initialize + @mutex.synchronize { + next if @initialized + @initialized = true + is_ipv6 = @host.index(':') + sock = UDPSocket.new(is_ipv6 ? Socket::AF_INET6 : Socket::AF_INET) + @socks = [sock] + sock.do_not_reverse_lookup = true + DNS.bind_random_port(sock, is_ipv6 ? "::" : "0.0.0.0") + sock.connect(@host, @port) + } + self + end + + def recv_reply(readable_socks) + lazy_initialize + reply = readable_socks[0].recv(UDPSize) + return reply, nil + end + + def sender(msg, data, host=@host, port=@port) + lazy_initialize + unless host == @host && port == @port + raise RequestError.new("host/port don't match: #{host}:#{port}") + end + id = DNS.allocate_request_id(@host, @port) + request = msg.encode + request[0,2] = [id].pack('n') + return @senders[[nil,id]] = Sender.new(request, data, @socks[0]) + end + + def close + @mutex.synchronize do + if @initialized + super + @senders.each_key {|from, id| + DNS.free_request_id(@host, @port, id) + } + @initialized = false + end + end + end + + class Sender < Requester::Sender # :nodoc: + def send + raise "@sock is nil." if @sock.nil? + @sock.send(@msg, 0) + end + attr_reader :data + end + end + + class MDNSOneShot < UnconnectedUDP # :nodoc: + def sender(msg, data, host, port=Port) + lazy_initialize + id = DNS.allocate_request_id(host, port) + request = msg.encode + request[0,2] = [id].pack('n') + sock = @socks_hash[host.index(':') ? "::" : "0.0.0.0"] + return @senders[id] = + UnconnectedUDP::Sender.new(request, data, sock, host, port) + end + + def sender_for(addr, msg) + lazy_initialize + @senders[msg.id] + end + end + + class TCP < Requester # :nodoc: + def initialize(host, port=Port) + super() + @host = host + @port = port + sock = TCPSocket.new(@host, @port) + @socks = [sock] + @senders = {} + end + + def recv_reply(readable_socks) + len = readable_socks[0].read(2).unpack('n')[0] + reply = @socks[0].read(len) + return reply, nil + end + + def sender(msg, data, host=@host, port=@port) + unless host == @host && port == @port + raise RequestError.new("host/port don't match: #{host}:#{port}") + end + id = DNS.allocate_request_id(@host, @port) + request = msg.encode + request[0,2] = [request.length, id].pack('nn') + return @senders[[nil,id]] = Sender.new(request, data, @socks[0]) + end + + class Sender < Requester::Sender # :nodoc: + def send + @sock.print(@msg) + @sock.flush + end + attr_reader :data + end + + def close + super + @senders.each_key {|from,id| + DNS.free_request_id(@host, @port, id) + } + end + end + + ## + # Indicates a problem with the DNS request. + + class RequestError < StandardError + end + end + + class Config # :nodoc: + def initialize(config_info=nil) + @mutex = Thread::Mutex.new + @config_info = config_info + @initialized = nil + @timeouts = nil + end + + def timeouts=(values) + if values + values = Array(values) + values.each do |t| + Numeric === t or raise ArgumentError, "#{t.inspect} is not numeric" + t > 0.0 or raise ArgumentError, "timeout=#{t} must be positive" + end + @timeouts = values + else + @timeouts = nil + end + end + + def Config.parse_resolv_conf(filename) + nameserver = [] + search = nil + ndots = 1 + File.open(filename, 'rb') {|f| + f.each {|line| + line.sub!(/[#;].*/, '') + keyword, *args = line.split(/\s+/) + next unless keyword + case keyword + when 'nameserver' + nameserver.concat(args.each(&:freeze)) + when 'domain' + next if args.empty? + search = [args[0].freeze] + when 'search' + next if args.empty? + search = args.each(&:freeze) + when 'options' + args.each {|arg| + case arg + when /\Andots:(\d+)\z/ + ndots = $1.to_i + end + } + end + } + } + return { :nameserver => nameserver.freeze, :search => search.freeze, :ndots => ndots.freeze }.freeze + end + + def Config.default_config_hash(filename="/etc/resolv.conf") + if File.exist? filename + Config.parse_resolv_conf(filename) + elsif defined?(Win32::Resolv) + search, nameserver = Win32::Resolv.get_resolv_info + config_hash = {} + config_hash[:nameserver] = nameserver if nameserver + config_hash[:search] = [search].flatten if search + config_hash + else + {} + end + end + + def lazy_initialize + @mutex.synchronize { + unless @initialized + @nameserver_port = [] + @use_ipv6 = nil + @search = nil + @ndots = 1 + case @config_info + when nil + config_hash = Config.default_config_hash + when String + config_hash = Config.parse_resolv_conf(@config_info) + when Hash + config_hash = @config_info.dup + if String === config_hash[:nameserver] + config_hash[:nameserver] = [config_hash[:nameserver]] + end + if String === config_hash[:search] + config_hash[:search] = [config_hash[:search]] + end + else + raise ArgumentError.new("invalid resolv configuration: #{@config_info.inspect}") + end + if config_hash.include? :nameserver + @nameserver_port = config_hash[:nameserver].map {|ns| [ns, Port] } + end + if config_hash.include? :nameserver_port + @nameserver_port = config_hash[:nameserver_port].map {|ns, port| [ns, (port || Port)] } + end + if config_hash.include? :use_ipv6 + @use_ipv6 = config_hash[:use_ipv6] + end + @search = config_hash[:search] if config_hash.include? :search + @ndots = config_hash[:ndots] if config_hash.include? :ndots + @raise_timeout_errors = config_hash[:raise_timeout_errors] + + if @nameserver_port.empty? + @nameserver_port << ['0.0.0.0', Port] + end + if @search + @search = @search.map {|arg| Label.split(arg) } + else + hostname = Socket.gethostname + if /\./ =~ hostname + @search = [Label.split($')] + else + @search = [[]] + end + end + + if !@nameserver_port.kind_of?(Array) || + @nameserver_port.any? {|ns_port| + !(Array === ns_port) || + ns_port.length != 2 + !(String === ns_port[0]) || + !(Integer === ns_port[1]) + } + raise ArgumentError.new("invalid nameserver config: #{@nameserver_port.inspect}") + end + + if !@search.kind_of?(Array) || + !@search.all? {|ls| ls.all? {|l| Label::Str === l } } + raise ArgumentError.new("invalid search config: #{@search.inspect}") + end + + if !@ndots.kind_of?(Integer) + raise ArgumentError.new("invalid ndots config: #{@ndots.inspect}") + end + + @initialized = true + end + } + self + end + + def single? + lazy_initialize + if @nameserver_port.length == 1 + return @nameserver_port[0] + else + return nil + end + end + + def nameserver_port + @nameserver_port + end + + def use_ipv6? + @use_ipv6 + end + + def generate_candidates(name) + candidates = nil + name = Name.create(name) + if name.absolute? + candidates = [name] + else + if @ndots <= name.length - 1 + candidates = [Name.new(name.to_a)] + else + candidates = [] + end + candidates.concat(@search.map {|domain| Name.new(name.to_a + domain)}) + fname = Name.create("#{name}.") + if !candidates.include?(fname) + candidates << fname + end + end + return candidates + end + + InitialTimeout = 5 + + def generate_timeouts + ts = [InitialTimeout] + ts << ts[-1] * 2 / @nameserver_port.length + ts << ts[-1] * 2 + ts << ts[-1] * 2 + return ts + end + + def resolv(name) + candidates = generate_candidates(name) + timeouts = @timeouts || generate_timeouts + timeout_error = false + begin + candidates.each {|candidate| + begin + timeouts.each {|tout| + @nameserver_port.each {|nameserver, port| + begin + yield candidate, tout, nameserver, port + rescue ResolvTimeout + end + } + } + timeout_error = true + raise ResolvError.new("DNS resolv timeout: #{name}") + rescue NXDomain + end + } + rescue ResolvError + raise if @raise_timeout_errors && timeout_error + end + end + + ## + # Indicates no such domain was found. + + class NXDomain < ResolvError + end + + ## + # Indicates some other unhandled resolver error was encountered. + + class OtherResolvError < ResolvError + end + end + + module OpCode # :nodoc: + Query = 0 + IQuery = 1 + Status = 2 + Notify = 4 + Update = 5 + end + + module RCode # :nodoc: + NoError = 0 + FormErr = 1 + ServFail = 2 + NXDomain = 3 + NotImp = 4 + Refused = 5 + YXDomain = 6 + YXRRSet = 7 + NXRRSet = 8 + NotAuth = 9 + NotZone = 10 + BADVERS = 16 + BADSIG = 16 + BADKEY = 17 + BADTIME = 18 + BADMODE = 19 + BADNAME = 20 + BADALG = 21 + end + + ## + # Indicates that the DNS response was unable to be decoded. + + class DecodeError < StandardError + end + + ## + # Indicates that the DNS request was unable to be encoded. + + class EncodeError < StandardError + end + + module Label # :nodoc: + def self.split(arg) + labels = [] + arg.scan(/[^\.]+/) {labels << Str.new($&)} + return labels + end + + class Str # :nodoc: + def initialize(string) + @string = string + # case insensivity of DNS labels doesn't apply non-ASCII characters. [RFC 4343] + # This assumes @string is given in ASCII compatible encoding. + @downcase = string.b.downcase + end + attr_reader :string, :downcase + + def to_s + return @string + end + + def inspect + return "#<#{self.class} #{self}>" + end + + def ==(other) + return self.class == other.class && @downcase == other.downcase + end + + def eql?(other) + return self == other + end + + def hash + return @downcase.hash + end + end + end + + ## + # A representation of a DNS name. + + class Name + + ## + # Creates a new DNS name from +arg+. +arg+ can be: + # + # Name:: returns +arg+. + # String:: Creates a new Name. + + def self.create(arg) + case arg + when Name + return arg + when String + return Name.new(Label.split(arg), /\.\z/ =~ arg ? true : false) + else + raise ArgumentError.new("cannot interpret as DNS name: #{arg.inspect}") + end + end + + def initialize(labels, absolute=true) # :nodoc: + labels = labels.map {|label| + case label + when String then Label::Str.new(label) + when Label::Str then label + else + raise ArgumentError, "unexpected label: #{label.inspect}" + end + } + @labels = labels + @absolute = absolute + end + + def inspect # :nodoc: + "#<#{self.class}: #{self}#{@absolute ? '.' : ''}>" + end + + ## + # True if this name is absolute. + + def absolute? + return @absolute + end + + def ==(other) # :nodoc: + return false unless Name === other + return false unless @absolute == other.absolute? + return @labels == other.to_a + end + + alias eql? == # :nodoc: + + ## + # Returns true if +other+ is a subdomain. + # + # Example: + # + # domain = Gem::Resolv::DNS::Name.create("y.z") + # p Gem::Resolv::DNS::Name.create("w.x.y.z").subdomain_of?(domain) #=> true + # p Gem::Resolv::DNS::Name.create("x.y.z").subdomain_of?(domain) #=> true + # p Gem::Resolv::DNS::Name.create("y.z").subdomain_of?(domain) #=> false + # p Gem::Resolv::DNS::Name.create("z").subdomain_of?(domain) #=> false + # p Gem::Resolv::DNS::Name.create("x.y.z.").subdomain_of?(domain) #=> false + # p Gem::Resolv::DNS::Name.create("w.z").subdomain_of?(domain) #=> false + # + + def subdomain_of?(other) + raise ArgumentError, "not a domain name: #{other.inspect}" unless Name === other + return false if @absolute != other.absolute? + other_len = other.length + return false if @labels.length <= other_len + return @labels[-other_len, other_len] == other.to_a + end + + def hash # :nodoc: + return @labels.hash ^ @absolute.hash + end + + def to_a # :nodoc: + return @labels + end + + def length # :nodoc: + return @labels.length + end + + def [](i) # :nodoc: + return @labels[i] + end + + ## + # returns the domain name as a string. + # + # The domain name doesn't have a trailing dot even if the name object is + # absolute. + # + # Example: + # + # p Gem::Resolv::DNS::Name.create("x.y.z.").to_s #=> "x.y.z" + # p Gem::Resolv::DNS::Name.create("x.y.z").to_s #=> "x.y.z" + + def to_s + return @labels.join('.') + end + end + + class Message # :nodoc: + @@identifier = -1 + + def initialize(id = (@@identifier += 1) & 0xffff) + @id = id + @qr = 0 + @opcode = 0 + @aa = 0 + @tc = 0 + @rd = 0 # recursion desired + @ra = 0 # recursion available + @rcode = 0 + @question = [] + @answer = [] + @authority = [] + @additional = [] + end + + attr_accessor :id, :qr, :opcode, :aa, :tc, :rd, :ra, :rcode + attr_reader :question, :answer, :authority, :additional + + def ==(other) + return @id == other.id && + @qr == other.qr && + @opcode == other.opcode && + @aa == other.aa && + @tc == other.tc && + @rd == other.rd && + @ra == other.ra && + @rcode == other.rcode && + @question == other.question && + @answer == other.answer && + @authority == other.authority && + @additional == other.additional + end + + def add_question(name, typeclass) + @question << [Name.create(name), typeclass] + end + + def each_question + @question.each {|name, typeclass| + yield name, typeclass + } + end + + def add_answer(name, ttl, data) + @answer << [Name.create(name), ttl, data] + end + + def each_answer + @answer.each {|name, ttl, data| + yield name, ttl, data + } + end + + def add_authority(name, ttl, data) + @authority << [Name.create(name), ttl, data] + end + + def each_authority + @authority.each {|name, ttl, data| + yield name, ttl, data + } + end + + def add_additional(name, ttl, data) + @additional << [Name.create(name), ttl, data] + end + + def each_additional + @additional.each {|name, ttl, data| + yield name, ttl, data + } + end + + def each_resource + each_answer {|name, ttl, data| yield name, ttl, data} + each_authority {|name, ttl, data| yield name, ttl, data} + each_additional {|name, ttl, data| yield name, ttl, data} + end + + def encode + return MessageEncoder.new {|msg| + msg.put_pack('nnnnnn', + @id, + (@qr & 1) << 15 | + (@opcode & 15) << 11 | + (@aa & 1) << 10 | + (@tc & 1) << 9 | + (@rd & 1) << 8 | + (@ra & 1) << 7 | + (@rcode & 15), + @question.length, + @answer.length, + @authority.length, + @additional.length) + @question.each {|q| + name, typeclass = q + msg.put_name(name) + msg.put_pack('nn', typeclass::TypeValue, typeclass::ClassValue) + } + [@answer, @authority, @additional].each {|rr| + rr.each {|r| + name, ttl, data = r + msg.put_name(name) + msg.put_pack('nnN', data.class::TypeValue, data.class::ClassValue, ttl) + msg.put_length16 {data.encode_rdata(msg)} + } + } + }.to_s + end + + class MessageEncoder # :nodoc: + def initialize + @data = ''.dup + @names = {} + yield self + end + + def to_s + return @data + end + + def put_bytes(d) + @data << d + end + + def put_pack(template, *d) + @data << d.pack(template) + end + + def put_length16 + length_index = @data.length + @data << "\0\0" + data_start = @data.length + yield + data_end = @data.length + @data[length_index, 2] = [data_end - data_start].pack("n") + end + + def put_string(d) + self.put_pack("C", d.length) + @data << d + end + + def put_string_list(ds) + ds.each {|d| + self.put_string(d) + } + end + + def put_name(d, compress: true) + put_labels(d.to_a, compress: compress) + end + + def put_labels(d, compress: true) + d.each_index {|i| + domain = d[i..-1] + if compress && idx = @names[domain] + self.put_pack("n", 0xc000 | idx) + return + else + if @data.length < 0x4000 + @names[domain] = @data.length + end + self.put_label(d[i]) + end + } + @data << "\0" + end + + def put_label(d) + self.put_string(d.to_s) + end + end + + def Message.decode(m) + o = Message.new(0) + MessageDecoder.new(m) {|msg| + id, flag, qdcount, ancount, nscount, arcount = + msg.get_unpack('nnnnnn') + o.id = id + o.tc = (flag >> 9) & 1 + o.rcode = flag & 15 + return o unless o.tc.zero? + + o.qr = (flag >> 15) & 1 + o.opcode = (flag >> 11) & 15 + o.aa = (flag >> 10) & 1 + o.rd = (flag >> 8) & 1 + o.ra = (flag >> 7) & 1 + (1..qdcount).each { + name, typeclass = msg.get_question + o.add_question(name, typeclass) + } + (1..ancount).each { + name, ttl, data = msg.get_rr + o.add_answer(name, ttl, data) + } + (1..nscount).each { + name, ttl, data = msg.get_rr + o.add_authority(name, ttl, data) + } + (1..arcount).each { + name, ttl, data = msg.get_rr + o.add_additional(name, ttl, data) + } + } + return o + end + + class MessageDecoder # :nodoc: + def initialize(data) + @data = data + @index = 0 + @limit = data.bytesize + yield self + end + + def inspect + "\#<#{self.class}: #{@data.byteslice(0, @index).inspect} #{@data.byteslice(@index..-1).inspect}>" + end + + def get_length16 + len, = self.get_unpack('n') + save_limit = @limit + @limit = @index + len + d = yield(len) + if @index < @limit + raise DecodeError.new("junk exists") + elsif @limit < @index + raise DecodeError.new("limit exceeded") + end + @limit = save_limit + return d + end + + def get_bytes(len = @limit - @index) + raise DecodeError.new("limit exceeded") if @limit < @index + len + d = @data.byteslice(@index, len) + @index += len + return d + end + + def get_unpack(template) + len = 0 + template.each_byte {|byte| + byte = "%c" % byte + case byte + when ?c, ?C + len += 1 + when ?n + len += 2 + when ?N + len += 4 + else + raise StandardError.new("unsupported template: '#{byte.chr}' in '#{template}'") + end + } + raise DecodeError.new("limit exceeded") if @limit < @index + len + arr = @data.unpack("@#{@index}#{template}") + @index += len + return arr + end + + def get_string + raise DecodeError.new("limit exceeded") if @limit <= @index + len = @data.getbyte(@index) + raise DecodeError.new("limit exceeded") if @limit < @index + 1 + len + d = @data.byteslice(@index + 1, len) + @index += 1 + len + return d + end + + def get_string_list + strings = [] + while @index < @limit + strings << self.get_string + end + strings + end + + def get_list + [].tap do |values| + while @index < @limit + values << yield + end + end + end + + def get_name + return Name.new(self.get_labels) + end + + def get_labels + prev_index = @index + save_index = nil + d = [] + size = -1 + while true + raise DecodeError.new("limit exceeded") if @limit <= @index + case @data.getbyte(@index) + when 0 + @index += 1 + if save_index + @index = save_index + end + return d + when 192..255 + idx = self.get_unpack('n')[0] & 0x3fff + if prev_index <= idx + raise DecodeError.new("non-backward name pointer") + end + prev_index = idx + if !save_index + save_index = @index + end + @index = idx + else + l = self.get_label + d << l + size += 1 + l.string.bytesize + raise DecodeError.new("name label data exceed 255 octets") if size > 255 + end + end + end + + def get_label + return Label::Str.new(self.get_string) + end + + def get_question + name = self.get_name + type, klass = self.get_unpack("nn") + return name, Resource.get_class(type, klass) + end + + def get_rr + name = self.get_name + type, klass, ttl = self.get_unpack('nnN') + typeclass = Resource.get_class(type, klass) + res = self.get_length16 do + begin + typeclass.decode_rdata self + rescue => e + raise DecodeError, e.message, e.backtrace + end + end + res.instance_variable_set :@ttl, ttl + return name, ttl, res + end + end + end + + ## + # SvcParams for service binding RRs. [RFC9460] + + class SvcParams + include Enumerable + + ## + # Create a list of SvcParams with the given initial content. + # + # +params+ has to be an enumerable of +SvcParam+s. + # If its content has +SvcParam+s with the duplicate key, + # the one appears last takes precedence. + + def initialize(params = []) + @params = {} + + params.each do |param| + add param + end + end + + ## + # Get SvcParam for the given +key+ in this list. + + def [](key) + @params[canonical_key(key)] + end + + ## + # Get the number of SvcParams in this list. + + def count + @params.count + end + + ## + # Get whether this list is empty. + + def empty? + @params.empty? + end + + ## + # Add the SvcParam +param+ to this list, overwriting the existing one with the same key. + + def add(param) + @params[param.class.key_number] = param + end + + ## + # Remove the +SvcParam+ with the given +key+ and return it. + + def delete(key) + @params.delete(canonical_key(key)) + end + + ## + # Enumerate the +SvcParam+s in this list. + + def each(&block) + return enum_for(:each) unless block + @params.each_value(&block) + end + + def encode(msg) # :nodoc: + @params.keys.sort.each do |key| + msg.put_pack('n', key) + msg.put_length16 do + @params.fetch(key).encode(msg) + end + end + end + + def self.decode(msg) # :nodoc: + params = msg.get_list do + key, = msg.get_unpack('n') + msg.get_length16 do + SvcParam::ClassHash[key].decode(msg) + end + end + + return self.new(params) + end + + private + + def canonical_key(key) # :nodoc: + case key + when Integer + key + when /\Akey(\d+)\z/ + Integer($1) + when Symbol + SvcParam::ClassHash[key].key_number + else + raise TypeError, 'key must be either String or Symbol' + end + end + end + + ## + # Base class for SvcParam. [RFC9460] + + class SvcParam + + ## + # Get the presentation name of the SvcParamKey. + + def self.key_name + const_get(:KeyName) + end + + ## + # Get the registered number of the SvcParamKey. + + def self.key_number + const_get(:KeyNumber) + end + + ClassHash = Hash.new do |h, key| # :nodoc: + case key + when Integer + Generic.create(key) + when /\Akey(?<key>\d+)\z/ + Generic.create(key.to_int) + when Symbol + raise KeyError, "unknown key #{key}" + else + raise TypeError, 'key must be either String or Symbol' + end + end + + ## + # Generic SvcParam abstract class. + + class Generic < SvcParam + + ## + # SvcParamValue in wire-format byte string. + + attr_reader :value + + ## + # Create generic SvcParam + + def initialize(value) + @value = value + end + + def encode(msg) # :nodoc: + msg.put_bytes(@value) + end + + def self.decode(msg) # :nodoc: + return self.new(msg.get_bytes) + end + + def self.create(key_number) + c = Class.new(Generic) + key_name = :"key#{key_number}" + c.const_set(:KeyName, key_name) + c.const_set(:KeyNumber, key_number) + self.const_set(:"Key#{key_number}", c) + ClassHash[key_name] = ClassHash[key_number] = c + return c + end + end + + ## + # "mandatory" SvcParam -- Mandatory keys in service binding RR + + class Mandatory < SvcParam + KeyName = :mandatory + KeyNumber = 0 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Mandatory keys. + + attr_reader :keys + + ## + # Initialize "mandatory" ScvParam. + + def initialize(keys) + @keys = keys.map(&:to_int) + end + + def encode(msg) # :nodoc: + @keys.sort.each do |key| + msg.put_pack('n', key) + end + end + + def self.decode(msg) # :nodoc: + keys = msg.get_list { msg.get_unpack('n')[0] } + return self.new(keys) + end + end + + ## + # "alpn" SvcParam -- Additional supported protocols + + class ALPN < SvcParam + KeyName = :alpn + KeyNumber = 1 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Supported protocol IDs. + + attr_reader :protocol_ids + + ## + # Initialize "alpn" ScvParam. + + def initialize(protocol_ids) + @protocol_ids = protocol_ids.map(&:to_str) + end + + def encode(msg) # :nodoc: + msg.put_string_list(@protocol_ids) + end + + def self.decode(msg) # :nodoc: + return self.new(msg.get_string_list) + end + end + + ## + # "no-default-alpn" SvcParam -- No support for default protocol + + class NoDefaultALPN < SvcParam + KeyName = :'no-default-alpn' + KeyNumber = 2 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + def encode(msg) # :nodoc: + # no payload + end + + def self.decode(msg) # :nodoc: + return self.new + end + end + + ## + # "port" SvcParam -- Port for alternative endpoint + + class Port < SvcParam + KeyName = :port + KeyNumber = 3 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Port number. + + attr_reader :port + + ## + # Initialize "port" ScvParam. + + def initialize(port) + @port = port.to_int + end + + def encode(msg) # :nodoc: + msg.put_pack('n', @port) + end + + def self.decode(msg) # :nodoc: + port, = msg.get_unpack('n') + return self.new(port) + end + end + + ## + # "ipv4hint" SvcParam -- IPv4 address hints + + class IPv4Hint < SvcParam + KeyName = :ipv4hint + KeyNumber = 4 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Set of IPv4 addresses. + + attr_reader :addresses + + ## + # Initialize "ipv4hint" ScvParam. + + def initialize(addresses) + @addresses = addresses.map {|address| IPv4.create(address) } + end + + def encode(msg) # :nodoc: + @addresses.each do |address| + msg.put_bytes(address.address) + end + end + + def self.decode(msg) # :nodoc: + addresses = msg.get_list { IPv4.new(msg.get_bytes(4)) } + return self.new(addresses) + end + end + + ## + # "ipv6hint" SvcParam -- IPv6 address hints + + class IPv6Hint < SvcParam + KeyName = :ipv6hint + KeyNumber = 6 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Set of IPv6 addresses. + + attr_reader :addresses + + ## + # Initialize "ipv6hint" ScvParam. + + def initialize(addresses) + @addresses = addresses.map {|address| IPv6.create(address) } + end + + def encode(msg) # :nodoc: + @addresses.each do |address| + msg.put_bytes(address.address) + end + end + + def self.decode(msg) # :nodoc: + addresses = msg.get_list { IPv6.new(msg.get_bytes(16)) } + return self.new(addresses) + end + end + + ## + # "dohpath" SvcParam -- DNS over HTTPS path template [RFC9461] + + class DoHPath < SvcParam + KeyName = :dohpath + KeyNumber = 7 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # URI template for DoH queries. + + attr_reader :template + + ## + # Initialize "dohpath" ScvParam. + + def initialize(template) + @template = template.encode('utf-8') + end + + def encode(msg) # :nodoc: + msg.put_bytes(@template) + end + + def self.decode(msg) # :nodoc: + template = msg.get_bytes.force_encoding('utf-8') + return self.new(template) + end + end + end + + ## + # A DNS query abstract class. + + class Query + def encode_rdata(msg) # :nodoc: + raise EncodeError.new("#{self.class} is query.") + end + + def self.decode_rdata(msg) # :nodoc: + raise DecodeError.new("#{self.class} is query.") + end + end + + ## + # A DNS resource abstract class. + + class Resource < Query + + ## + # Remaining Time To Live for this Resource. + + attr_reader :ttl + + ClassHash = Module.new do + module_function + + def []=(type_class_value, klass) + type_value, class_value = type_class_value + Resource.const_set(:"Type#{type_value}_Class#{class_value}", klass) + end + end + + def encode_rdata(msg) # :nodoc: + raise NotImplementedError.new + end + + def self.decode_rdata(msg) # :nodoc: + raise NotImplementedError.new + end + + def ==(other) # :nodoc: + return false unless self.class == other.class + s_ivars = self.instance_variables + s_ivars.sort! + s_ivars.delete :@ttl + o_ivars = other.instance_variables + o_ivars.sort! + o_ivars.delete :@ttl + return s_ivars == o_ivars && + s_ivars.collect {|name| self.instance_variable_get name} == + o_ivars.collect {|name| other.instance_variable_get name} + end + + def eql?(other) # :nodoc: + return self == other + end + + def hash # :nodoc: + h = 0 + vars = self.instance_variables + vars.delete :@ttl + vars.each {|name| + h ^= self.instance_variable_get(name).hash + } + return h + end + + def self.get_class(type_value, class_value) # :nodoc: + cache = :"Type#{type_value}_Class#{class_value}" + + return (const_defined?(cache) && const_get(cache)) || + Generic.create(type_value, class_value) + end + + ## + # A generic resource abstract class. + + class Generic < Resource + + ## + # Creates a new generic resource. + + def initialize(data) + @data = data + end + + ## + # Data for this generic resource. + + attr_reader :data + + def encode_rdata(msg) # :nodoc: + msg.put_bytes(data) + end + + def self.decode_rdata(msg) # :nodoc: + return self.new(msg.get_bytes) + end + + def self.create(type_value, class_value) # :nodoc: + c = Class.new(Generic) + c.const_set(:TypeValue, type_value) + c.const_set(:ClassValue, class_value) + Generic.const_set("Type#{type_value}_Class#{class_value}", c) + ClassHash[[type_value, class_value]] = c + return c + end + end + + ## + # Domain Name resource abstract class. + + class DomainName < Resource + + ## + # Creates a new DomainName from +name+. + + def initialize(name) + @name = name + end + + ## + # The name of this DomainName. + + attr_reader :name + + def encode_rdata(msg) # :nodoc: + msg.put_name(@name) + end + + def self.decode_rdata(msg) # :nodoc: + return self.new(msg.get_name) + end + end + + # Standard (class generic) RRs + + ClassValue = nil # :nodoc: + + ## + # An authoritative name server. + + class NS < DomainName + TypeValue = 2 # :nodoc: + end + + ## + # The canonical name for an alias. + + class CNAME < DomainName + TypeValue = 5 # :nodoc: + end + + ## + # Start Of Authority resource. + + class SOA < Resource + + TypeValue = 6 # :nodoc: + + ## + # Creates a new SOA record. See the attr documentation for the + # details of each argument. + + def initialize(mname, rname, serial, refresh, retry_, expire, minimum) + @mname = mname + @rname = rname + @serial = serial + @refresh = refresh + @retry = retry_ + @expire = expire + @minimum = minimum + end + + ## + # Name of the host where the master zone file for this zone resides. + + attr_reader :mname + + ## + # The person responsible for this domain name. + + attr_reader :rname + + ## + # The version number of the zone file. + + attr_reader :serial + + ## + # How often, in seconds, a secondary name server is to check for + # updates from the primary name server. + + attr_reader :refresh + + ## + # How often, in seconds, a secondary name server is to retry after a + # failure to check for a refresh. + + attr_reader :retry + + ## + # Time in seconds that a secondary name server is to use the data + # before refreshing from the primary name server. + + attr_reader :expire + + ## + # The minimum number of seconds to be used for TTL values in RRs. + + attr_reader :minimum + + def encode_rdata(msg) # :nodoc: + msg.put_name(@mname) + msg.put_name(@rname) + msg.put_pack('NNNNN', @serial, @refresh, @retry, @expire, @minimum) + end + + def self.decode_rdata(msg) # :nodoc: + mname = msg.get_name + rname = msg.get_name + serial, refresh, retry_, expire, minimum = msg.get_unpack('NNNNN') + return self.new( + mname, rname, serial, refresh, retry_, expire, minimum) + end + end + + ## + # A Pointer to another DNS name. + + class PTR < DomainName + TypeValue = 12 # :nodoc: + end + + ## + # Host Information resource. + + class HINFO < Resource + + TypeValue = 13 # :nodoc: + + ## + # Creates a new HINFO running +os+ on +cpu+. + + def initialize(cpu, os) + @cpu = cpu + @os = os + end + + ## + # CPU architecture for this resource. + + attr_reader :cpu + + ## + # Operating system for this resource. + + attr_reader :os + + def encode_rdata(msg) # :nodoc: + msg.put_string(@cpu) + msg.put_string(@os) + end + + def self.decode_rdata(msg) # :nodoc: + cpu = msg.get_string + os = msg.get_string + return self.new(cpu, os) + end + end + + ## + # Mailing list or mailbox information. + + class MINFO < Resource + + TypeValue = 14 # :nodoc: + + def initialize(rmailbx, emailbx) + @rmailbx = rmailbx + @emailbx = emailbx + end + + ## + # Domain name responsible for this mail list or mailbox. + + attr_reader :rmailbx + + ## + # Mailbox to use for error messages related to the mail list or mailbox. + + attr_reader :emailbx + + def encode_rdata(msg) # :nodoc: + msg.put_name(@rmailbx) + msg.put_name(@emailbx) + end + + def self.decode_rdata(msg) # :nodoc: + rmailbx = msg.get_string + emailbx = msg.get_string + return self.new(rmailbx, emailbx) + end + end + + ## + # Mail Exchanger resource. + + class MX < Resource + + TypeValue= 15 # :nodoc: + + ## + # Creates a new MX record with +preference+, accepting mail at + # +exchange+. + + def initialize(preference, exchange) + @preference = preference + @exchange = exchange + end + + ## + # The preference for this MX. + + attr_reader :preference + + ## + # The host of this MX. + + attr_reader :exchange + + def encode_rdata(msg) # :nodoc: + msg.put_pack('n', @preference) + msg.put_name(@exchange) + end + + def self.decode_rdata(msg) # :nodoc: + preference, = msg.get_unpack('n') + exchange = msg.get_name + return self.new(preference, exchange) + end + end + + ## + # Unstructured text resource. + + class TXT < Resource + + TypeValue = 16 # :nodoc: + + def initialize(first_string, *rest_strings) + @strings = [first_string, *rest_strings] + end + + ## + # Returns an Array of Strings for this TXT record. + + attr_reader :strings + + ## + # Returns the concatenated string from +strings+. + + def data + @strings.join("") + end + + def encode_rdata(msg) # :nodoc: + msg.put_string_list(@strings) + end + + def self.decode_rdata(msg) # :nodoc: + strings = msg.get_string_list + return self.new(*strings) + end + end + + ## + # Location resource + + class LOC < Resource + + TypeValue = 29 # :nodoc: + + def initialize(version, ssize, hprecision, vprecision, latitude, longitude, altitude) + @version = version + @ssize = Gem::Resolv::LOC::Size.create(ssize) + @hprecision = Gem::Resolv::LOC::Size.create(hprecision) + @vprecision = Gem::Resolv::LOC::Size.create(vprecision) + @latitude = Gem::Resolv::LOC::Coord.create(latitude) + @longitude = Gem::Resolv::LOC::Coord.create(longitude) + @altitude = Gem::Resolv::LOC::Alt.create(altitude) + end + + ## + # Returns the version value for this LOC record which should always be 00 + + attr_reader :version + + ## + # The spherical size of this LOC + # in meters using scientific notation as 2 integers of XeY + + attr_reader :ssize + + ## + # The horizontal precision using ssize type values + # in meters using scientific notation as 2 integers of XeY + # for precision use value/2 e.g. 2m = +/-1m + + attr_reader :hprecision + + ## + # The vertical precision using ssize type values + # in meters using scientific notation as 2 integers of XeY + # for precision use value/2 e.g. 2m = +/-1m + + attr_reader :vprecision + + ## + # The latitude for this LOC where 2**31 is the equator + # in thousandths of an arc second as an unsigned 32bit integer + + attr_reader :latitude + + ## + # The longitude for this LOC where 2**31 is the prime meridian + # in thousandths of an arc second as an unsigned 32bit integer + + attr_reader :longitude + + ## + # The altitude of the LOC above a reference sphere whose surface sits 100km below the WGS84 spheroid + # in centimeters as an unsigned 32bit integer + + attr_reader :altitude + + def encode_rdata(msg) # :nodoc: + msg.put_bytes(@version) + msg.put_bytes(@ssize.scalar) + msg.put_bytes(@hprecision.scalar) + msg.put_bytes(@vprecision.scalar) + msg.put_bytes(@latitude.coordinates) + msg.put_bytes(@longitude.coordinates) + msg.put_bytes(@altitude.altitude) + end + + def self.decode_rdata(msg) # :nodoc: + version = msg.get_bytes(1) + ssize = msg.get_bytes(1) + hprecision = msg.get_bytes(1) + vprecision = msg.get_bytes(1) + latitude = msg.get_bytes(4) + longitude = msg.get_bytes(4) + altitude = msg.get_bytes(4) + return self.new( + version, + Gem::Resolv::LOC::Size.new(ssize), + Gem::Resolv::LOC::Size.new(hprecision), + Gem::Resolv::LOC::Size.new(vprecision), + Gem::Resolv::LOC::Coord.new(latitude,"lat"), + Gem::Resolv::LOC::Coord.new(longitude,"lon"), + Gem::Resolv::LOC::Alt.new(altitude) + ) + end + end + + ## + # A Query type requesting any RR. + + class ANY < Query + TypeValue = 255 # :nodoc: + end + + ## + # CAA resource record defined in RFC 8659 + # + # These records identify certificate authority allowed to issue + # certificates for the given domain. + + class CAA < Resource + TypeValue = 257 + + ## + # Creates a new CAA for +flags+, +tag+ and +value+. + + def initialize(flags, tag, value) + unless (0..255) === flags + raise ArgumentError.new('flags must be an Integer between 0 and 255') + end + unless (1..15) === tag.bytesize + raise ArgumentError.new('length of tag must be between 1 and 15') + end + + @flags = flags + @tag = tag + @value = value + end + + ## + # Flags for this property: + # - Bit 0 : 0 = not critical, 1 = critical + + attr_reader :flags + + ## + # Property tag ("issue", "issuewild", "iodef"...). + + attr_reader :tag + + ## + # Property value. + + attr_reader :value + + ## + # Whether the critical flag is set on this property. + + def critical? + flags & 0x80 != 0 + end + + def encode_rdata(msg) # :nodoc: + msg.put_pack('C', @flags) + msg.put_string(@tag) + msg.put_bytes(@value) + end + + def self.decode_rdata(msg) # :nodoc: + flags, = msg.get_unpack('C') + tag = msg.get_string + value = msg.get_bytes + self.new flags, tag, value + end + end + + ClassInsensitiveTypes = [ # :nodoc: + NS, CNAME, SOA, PTR, HINFO, MINFO, MX, TXT, LOC, ANY, CAA + ] + + ## + # module IN contains ARPA Internet specific RRs. + + module IN + + ClassValue = 1 # :nodoc: + + ClassInsensitiveTypes.each {|s| + c = Class.new(s) + c.const_set(:TypeValue, s::TypeValue) + c.const_set(:ClassValue, ClassValue) + ClassHash[[s::TypeValue, ClassValue]] = c + self.const_set(s.name.sub(/.*::/, ''), c) + } + + ## + # IPv4 Address resource + + class A < Resource + TypeValue = 1 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + + ## + # Creates a new A for +address+. + + def initialize(address) + @address = IPv4.create(address) + end + + ## + # The Gem::Resolv::IPv4 address for this A. + + attr_reader :address + + def encode_rdata(msg) # :nodoc: + msg.put_bytes(@address.address) + end + + def self.decode_rdata(msg) # :nodoc: + return self.new(IPv4.new(msg.get_bytes(4))) + end + end + + ## + # Well Known Service resource. + + class WKS < Resource + TypeValue = 11 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + + def initialize(address, protocol, bitmap) + @address = IPv4.create(address) + @protocol = protocol + @bitmap = bitmap + end + + ## + # The host these services run on. + + attr_reader :address + + ## + # IP protocol number for these services. + + attr_reader :protocol + + ## + # A bit map of enabled services on this host. + # + # If protocol is 6 (TCP) then the 26th bit corresponds to the SMTP + # service (port 25). If this bit is set, then an SMTP server should + # be listening on TCP port 25; if zero, SMTP service is not + # supported. + + attr_reader :bitmap + + def encode_rdata(msg) # :nodoc: + msg.put_bytes(@address.address) + msg.put_pack("n", @protocol) + msg.put_bytes(@bitmap) + end + + def self.decode_rdata(msg) # :nodoc: + address = IPv4.new(msg.get_bytes(4)) + protocol, = msg.get_unpack("n") + bitmap = msg.get_bytes + return self.new(address, protocol, bitmap) + end + end + + ## + # An IPv6 address record. + + class AAAA < Resource + TypeValue = 28 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + + ## + # Creates a new AAAA for +address+. + + def initialize(address) + @address = IPv6.create(address) + end + + ## + # The Gem::Resolv::IPv6 address for this AAAA. + + attr_reader :address + + def encode_rdata(msg) # :nodoc: + msg.put_bytes(@address.address) + end + + def self.decode_rdata(msg) # :nodoc: + return self.new(IPv6.new(msg.get_bytes(16))) + end + end + + ## + # SRV resource record defined in RFC 2782 + # + # These records identify the hostname and port that a service is + # available at. + + class SRV < Resource + TypeValue = 33 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + + # Create a SRV resource record. + # + # See the documentation for #priority, #weight, #port and #target + # for +priority+, +weight+, +port and +target+ respectively. + + def initialize(priority, weight, port, target) + @priority = priority.to_int + @weight = weight.to_int + @port = port.to_int + @target = Name.create(target) + end + + # The priority of this target host. + # + # A client MUST attempt to contact the target host with the + # lowest-numbered priority it can reach; target hosts with the same + # priority SHOULD be tried in an order defined by the weight field. + # The range is 0-65535. Note that it is not widely implemented and + # should be set to zero. + + attr_reader :priority + + # A server selection mechanism. + # + # The weight field specifies a relative weight for entries with the + # same priority. Larger weights SHOULD be given a proportionately + # higher probability of being selected. The range of this number is + # 0-65535. Domain administrators SHOULD use Weight 0 when there + # isn't any server selection to do, to make the RR easier to read + # for humans (less noisy). Note that it is not widely implemented + # and should be set to zero. + + attr_reader :weight + + # The port on this target host of this service. + # + # The range is 0-65535. + + attr_reader :port + + # The domain name of the target host. + # + # A target of "." means that the service is decidedly not available + # at this domain. + + attr_reader :target + + def encode_rdata(msg) # :nodoc: + msg.put_pack("n", @priority) + msg.put_pack("n", @weight) + msg.put_pack("n", @port) + msg.put_name(@target, compress: false) + end + + def self.decode_rdata(msg) # :nodoc: + priority, = msg.get_unpack("n") + weight, = msg.get_unpack("n") + port, = msg.get_unpack("n") + target = msg.get_name + return self.new(priority, weight, port, target) + end + end + + ## + # Common implementation for SVCB-compatible resource records. + + class ServiceBinding + + ## + # Create a service binding resource record. + + def initialize(priority, target, params = []) + @priority = priority.to_int + @target = Name.create(target) + @params = SvcParams.new(params) + end + + ## + # The priority of this target host. + # + # The range is 0-65535. + # If set to 0, this RR is in AliasMode. Otherwise, it is in ServiceMode. + + attr_reader :priority + + ## + # The domain name of the target host. + + attr_reader :target + + ## + # The service parameters for the target host. + + attr_reader :params + + ## + # Whether this RR is in AliasMode. + + def alias_mode? + self.priority == 0 + end + + ## + # Whether this RR is in ServiceMode. + + def service_mode? + !alias_mode? + end + + def encode_rdata(msg) # :nodoc: + msg.put_pack("n", @priority) + msg.put_name(@target, compress: false) + @params.encode(msg) + end + + def self.decode_rdata(msg) # :nodoc: + priority, = msg.get_unpack("n") + target = msg.get_name + params = SvcParams.decode(msg) + return self.new(priority, target, params) + end + end + + ## + # SVCB resource record [RFC9460] + + class SVCB < ServiceBinding + TypeValue = 64 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + end + + ## + # HTTPS resource record [RFC9460] + + class HTTPS < ServiceBinding + TypeValue = 65 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + end + end + end + end + + ## + # A Gem::Resolv::DNS IPv4 address. + + class IPv4 + + Regex256 = /0 + |1(?:[0-9][0-9]?)? + |2(?:[0-4][0-9]?|5[0-5]?|[6-9])? + |[3-9][0-9]?/x # :nodoc: + + ## + # Regular expression IPv4 addresses must match. + Regex = /\A(#{Regex256})\.(#{Regex256})\.(#{Regex256})\.(#{Regex256})\z/ + + ## + # Creates a new IPv4 address from +arg+ which may be: + # + # IPv4:: returns +arg+. + # String:: +arg+ must match the IPv4::Regex constant + + def self.create(arg) + case arg + when IPv4 + return arg + when Regex + if (0..255) === (a = $1.to_i) && + (0..255) === (b = $2.to_i) && + (0..255) === (c = $3.to_i) && + (0..255) === (d = $4.to_i) + return self.new([a, b, c, d].pack("CCCC")) + else + raise ArgumentError.new("IPv4 address with invalid value: " + arg) + end + else + raise ArgumentError.new("cannot interpret as IPv4 address: #{arg.inspect}") + end + end + + def initialize(address) # :nodoc: + unless address.kind_of?(String) + raise ArgumentError, 'IPv4 address must be a string' + end + unless address.length == 4 + raise ArgumentError, "IPv4 address expects 4 bytes but #{address.length} bytes" + end + @address = address + end + + ## + # A String representation of this IPv4 address. + + ## + # The raw IPv4 address as a String. + + attr_reader :address + + def to_s # :nodoc: + return sprintf("%d.%d.%d.%d", *@address.unpack("CCCC")) + end + + def inspect # :nodoc: + return "#<#{self.class} #{self}>" + end + + ## + # Turns this IPv4 address into a Gem::Resolv::DNS::Name. + + def to_name + return DNS::Name.create( + '%d.%d.%d.%d.in-addr.arpa.' % @address.unpack('CCCC').reverse) + end + + def ==(other) # :nodoc: + return @address == other.address + end + + def eql?(other) # :nodoc: + return self == other + end + + def hash # :nodoc: + return @address.hash + end + end + + ## + # A Gem::Resolv::DNS IPv6 address. + + class IPv6 + + ## + # IPv6 address format a:b:c:d:e:f:g:h + Regex_8Hex = /\A + (?:[0-9A-Fa-f]{1,4}:){7} + [0-9A-Fa-f]{1,4} + \z/x + + ## + # Compressed IPv6 address format a::b + + Regex_CompressedHex = /\A + ((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) :: + ((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) + \z/x + + ## + # IPv4 mapped IPv6 address format a:b:c:d:e:f:w.x.y.z + + Regex_6Hex4Dec = /\A + ((?:[0-9A-Fa-f]{1,4}:){6,6}) + (\d+)\.(\d+)\.(\d+)\.(\d+) + \z/x + + ## + # Compressed IPv4 mapped IPv6 address format a::b:w.x.y.z + + Regex_CompressedHex4Dec = /\A + ((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) :: + ((?:[0-9A-Fa-f]{1,4}:)*) + (\d+)\.(\d+)\.(\d+)\.(\d+) + \z/x + + ## + # IPv6 link local address format fe80:b:c:d:e:f:g:h%em1 + Regex_8HexLinkLocal = /\A + [Ff][Ee]80 + (?::[0-9A-Fa-f]{1,4}){7} + %[-0-9A-Za-z._~]+ + \z/x + + ## + # Compressed IPv6 link local address format fe80::b%em1 + + Regex_CompressedHexLinkLocal = /\A + [Ff][Ee]80: + (?: + ((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) :: + ((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) + | + :((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) + )? + :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+ + \z/x + + ## + # A composite IPv6 address Regexp. + + Regex = / + (?:#{Regex_8Hex}) | + (?:#{Regex_CompressedHex}) | + (?:#{Regex_6Hex4Dec}) | + (?:#{Regex_CompressedHex4Dec}) | + (?:#{Regex_8HexLinkLocal}) | + (?:#{Regex_CompressedHexLinkLocal}) + /x + + ## + # Creates a new IPv6 address from +arg+ which may be: + # + # IPv6:: returns +arg+. + # String:: +arg+ must match one of the IPv6::Regex* constants + + def self.create(arg) + case arg + when IPv6 + return arg + when String + address = ''.b + if Regex_8Hex =~ arg + arg.scan(/[0-9A-Fa-f]+/) {|hex| address << [hex.hex].pack('n')} + elsif Regex_CompressedHex =~ arg + prefix = $1 + suffix = $2 + a1 = ''.b + a2 = ''.b + prefix.scan(/[0-9A-Fa-f]+/) {|hex| a1 << [hex.hex].pack('n')} + suffix.scan(/[0-9A-Fa-f]+/) {|hex| a2 << [hex.hex].pack('n')} + omitlen = 16 - a1.length - a2.length + address << a1 << "\0" * omitlen << a2 + elsif Regex_6Hex4Dec =~ arg + prefix, a, b, c, d = $1, $2.to_i, $3.to_i, $4.to_i, $5.to_i + if (0..255) === a && (0..255) === b && (0..255) === c && (0..255) === d + prefix.scan(/[0-9A-Fa-f]+/) {|hex| address << [hex.hex].pack('n')} + address << [a, b, c, d].pack('CCCC') + else + raise ArgumentError.new("not numeric IPv6 address: " + arg) + end + elsif Regex_CompressedHex4Dec =~ arg + prefix, suffix, a, b, c, d = $1, $2, $3.to_i, $4.to_i, $5.to_i, $6.to_i + if (0..255) === a && (0..255) === b && (0..255) === c && (0..255) === d + a1 = ''.b + a2 = ''.b + prefix.scan(/[0-9A-Fa-f]+/) {|hex| a1 << [hex.hex].pack('n')} + suffix.scan(/[0-9A-Fa-f]+/) {|hex| a2 << [hex.hex].pack('n')} + omitlen = 12 - a1.length - a2.length + address << a1 << "\0" * omitlen << a2 << [a, b, c, d].pack('CCCC') + else + raise ArgumentError.new("not numeric IPv6 address: " + arg) + end + else + raise ArgumentError.new("not numeric IPv6 address: " + arg) + end + return IPv6.new(address) + else + raise ArgumentError.new("cannot interpret as IPv6 address: #{arg.inspect}") + end + end + + def initialize(address) # :nodoc: + unless address.kind_of?(String) && address.length == 16 + raise ArgumentError.new('IPv6 address must be 16 bytes') + end + @address = address + end + + ## + # The raw IPv6 address as a String. + + attr_reader :address + + def to_s # :nodoc: + sprintf("%x:%x:%x:%x:%x:%x:%x:%x", *@address.unpack("nnnnnnnn")).sub(/(^|:)0(:0)+(:|$)/, '::') + end + + def inspect # :nodoc: + return "#<#{self.class} #{self}>" + end + + ## + # Turns this IPv6 address into a Gem::Resolv::DNS::Name. + #-- + # ip6.arpa should be searched too. [RFC3152] + + def to_name + return DNS::Name.new( + @address.unpack("H32")[0].split(//).reverse + ['ip6', 'arpa']) + end + + def ==(other) # :nodoc: + return @address == other.address + end + + def eql?(other) # :nodoc: + return self == other + end + + def hash # :nodoc: + return @address.hash + end + end + + ## + # Gem::Resolv::MDNS is a one-shot Multicast DNS (mDNS) resolver. It blindly + # makes queries to the mDNS addresses without understanding anything about + # multicast ports. + # + # Information taken form the following places: + # + # * RFC 6762 + + class MDNS < DNS + + ## + # Default mDNS Port + + Port = 5353 + + ## + # Default IPv4 mDNS address + + AddressV4 = '224.0.0.251' + + ## + # Default IPv6 mDNS address + + AddressV6 = 'ff02::fb' + + ## + # Default mDNS addresses + + Addresses = [ + [AddressV4, Port], + [AddressV6, Port], + ] + + ## + # Creates a new one-shot Multicast DNS (mDNS) resolver. + # + # +config_info+ can be: + # + # nil:: + # Uses the default mDNS addresses + # + # Hash:: + # Must contain :nameserver or :nameserver_port like + # Gem::Resolv::DNS#initialize. + + def initialize(config_info=nil) + if config_info then + super({ nameserver_port: Addresses }.merge(config_info)) + else + super(nameserver_port: Addresses) + end + end + + ## + # Iterates over all IP addresses for +name+ retrieved from the mDNS + # resolver, provided name ends with "local". If the name does not end in + # "local" no records will be returned. + # + # +name+ can be a Gem::Resolv::DNS::Name or a String. Retrieved addresses will + # be a Gem::Resolv::IPv4 or Gem::Resolv::IPv6 + + def each_address(name) + name = Gem::Resolv::DNS::Name.create(name) + + return unless name[-1].to_s == 'local' + + super(name) + end + + def make_udp_requester # :nodoc: + nameserver_port = @config.nameserver_port + Requester::MDNSOneShot.new(*nameserver_port) + end + + end + + module LOC # :nodoc: + + ## + # A Gem::Resolv::LOC::Size + + class Size + + # Regular expression LOC size must match. + + Regex = /^(\d+\.*\d*)[m]$/ + + ## + # Creates a new LOC::Size from +arg+ which may be: + # + # LOC::Size:: returns +arg+. + # String:: +arg+ must match the LOC::Size::Regex constant + + def self.create(arg) + case arg + when Size + return arg + when String + scalar = '' + if Regex =~ arg + scalar = [(($1.to_f*(1e2)).to_i.to_s[0].to_i*(2**4)+(($1.to_f*(1e2)).to_i.to_s.length-1))].pack("C") + else + raise ArgumentError.new("not a properly formed Size string: " + arg) + end + return Size.new(scalar) + else + raise ArgumentError.new("cannot interpret as Size: #{arg.inspect}") + end + end + + # Internal use; use self.create. + def initialize(scalar) + @scalar = scalar + end + + ## + # The raw size + + attr_reader :scalar + + def to_s # :nodoc: + s = @scalar.unpack("H2").join.to_s + return ((s[0].to_i)*(10**(s[1].to_i-2))).to_s << "m" + end + + def inspect # :nodoc: + return "#<#{self.class} #{self}>" + end + + def ==(other) # :nodoc: + return @scalar == other.scalar + end + + def eql?(other) # :nodoc: + return self == other + end + + def hash # :nodoc: + return @scalar.hash + end + + end + + ## + # A Gem::Resolv::LOC::Coord + + class Coord + + # Regular expression LOC Coord must match. + + Regex = /^(\d+)\s(\d+)\s(\d+\.\d+)\s([NESW])$/ + + ## + # Creates a new LOC::Coord from +arg+ which may be: + # + # LOC::Coord:: returns +arg+. + # String:: +arg+ must match the LOC::Coord::Regex constant + + def self.create(arg) + case arg + when Coord + return arg + when String + coordinates = '' + if Regex =~ arg && $1.to_f < 180 + m = $~ + hemi = (m[4][/[NE]/]) || (m[4][/[SW]/]) ? 1 : -1 + coordinates = [ ((m[1].to_i*(36e5)) + (m[2].to_i*(6e4)) + + (m[3].to_f*(1e3))) * hemi+(2**31) ].pack("N") + orientation = m[4][/[NS]/] ? 'lat' : 'lon' + else + raise ArgumentError.new("not a properly formed Coord string: " + arg) + end + return Coord.new(coordinates,orientation) + else + raise ArgumentError.new("cannot interpret as Coord: #{arg.inspect}") + end + end + + # Internal use; use self.create. + def initialize(coordinates,orientation) + unless coordinates.kind_of?(String) + raise ArgumentError.new("Coord must be a 32bit unsigned integer in hex format: #{coordinates.inspect}") + end + unless orientation.kind_of?(String) && orientation[/^lon$|^lat$/] + raise ArgumentError.new('Coord expects orientation to be a String argument of "lat" or "lon"') + end + @coordinates = coordinates + @orientation = orientation + end + + ## + # The raw coordinates + + attr_reader :coordinates + + ## The orientation of the hemisphere as 'lat' or 'lon' + + attr_reader :orientation + + def to_s # :nodoc: + c = @coordinates.unpack("N").join.to_i + val = (c - (2**31)).abs + fracsecs = (val % 1e3).to_i.to_s + val = val / 1e3 + secs = (val % 60).to_i.to_s + val = val / 60 + mins = (val % 60).to_i.to_s + degs = (val / 60).to_i.to_s + posi = (c >= 2**31) + case posi + when true + hemi = @orientation[/^lat$/] ? "N" : "E" + else + hemi = @orientation[/^lon$/] ? "W" : "S" + end + return degs << " " << mins << " " << secs << "." << fracsecs << " " << hemi + end + + def inspect # :nodoc: + return "#<#{self.class} #{self}>" + end + + def ==(other) # :nodoc: + return @coordinates == other.coordinates + end + + def eql?(other) # :nodoc: + return self == other + end + + def hash # :nodoc: + return @coordinates.hash + end + + end + + ## + # A Gem::Resolv::LOC::Alt + + class Alt + + # Regular expression LOC Alt must match. + + Regex = /^([+-]*\d+\.*\d*)[m]$/ + + ## + # Creates a new LOC::Alt from +arg+ which may be: + # + # LOC::Alt:: returns +arg+. + # String:: +arg+ must match the LOC::Alt::Regex constant + + def self.create(arg) + case arg + when Alt + return arg + when String + altitude = '' + if Regex =~ arg + altitude = [($1.to_f*(1e2))+(1e7)].pack("N") + else + raise ArgumentError.new("not a properly formed Alt string: " + arg) + end + return Alt.new(altitude) + else + raise ArgumentError.new("cannot interpret as Alt: #{arg.inspect}") + end + end + + # Internal use; use self.create. + def initialize(altitude) + @altitude = altitude + end + + ## + # The raw altitude + + attr_reader :altitude + + def to_s # :nodoc: + a = @altitude.unpack("N").join.to_i + return ((a.to_f/1e2)-1e5).to_s + "m" + end + + def inspect # :nodoc: + return "#<#{self.class} #{self}>" + end + + def ==(other) # :nodoc: + return @altitude == other.altitude + end + + def eql?(other) # :nodoc: + return self == other + end + + def hash # :nodoc: + return @altitude.hash + end + + end + + end + + ## + # Default resolver to use for Gem::Resolv class methods. + + DefaultResolver = self.new + + ## + # Replaces the resolvers in the default resolver with +new_resolvers+. This + # allows resolvers to be changed for resolv-replace. + + def DefaultResolver.replace_resolvers new_resolvers + @resolvers = new_resolvers + end + + ## + # Address Regexp to use for matching IP addresses. + + AddressRegex = /(?:#{IPv4::Regex})|(?:#{IPv6::Regex})/ + +end diff --git a/lib/rubygems/vendor/securerandom/lib/securerandom.rb b/lib/rubygems/vendor/securerandom/lib/securerandom.rb new file mode 100644 index 0000000000..b6f1d71ad3 --- /dev/null +++ b/lib/rubygems/vendor/securerandom/lib/securerandom.rb @@ -0,0 +1,102 @@ +# -*- coding: us-ascii -*- +# frozen_string_literal: true + +require 'random/formatter' + +# == Secure random number generator interface. +# +# This library is an interface to secure random number generators which are +# suitable for generating session keys in HTTP cookies, etc. +# +# You can use this library in your application by requiring it: +# +# require 'rubygems/vendor/securerandom/lib/securerandom' +# +# It supports the following secure random number generators: +# +# * openssl +# * /dev/urandom +# * Win32 +# +# Gem::SecureRandom is extended by the Random::Formatter module which +# defines the following methods: +# +# * alphanumeric +# * base64 +# * choose +# * gen_random +# * hex +# * rand +# * random_bytes +# * random_number +# * urlsafe_base64 +# * uuid +# +# These methods are usable as class methods of Gem::SecureRandom such as +# +Gem::SecureRandom.hex+. +# +# If a secure random number generator is not available, +# +NotImplementedError+ is raised. + +module Gem::SecureRandom + + # The version + VERSION = "0.4.1" + + class << self + # Returns a random binary string containing +size+ bytes. + # + # See Random.bytes + def bytes(n) + return gen_random(n) + end + + # Compatibility methods for Ruby 3.2, we can remove this after dropping to support Ruby 3.2 + def alphanumeric(n = nil, chars: ALPHANUMERIC) + n = 16 if n.nil? + choose(chars, n) + end if RUBY_VERSION < '3.3' + + private + + # :stopdoc: + + # Implementation using OpenSSL + def gen_random_openssl(n) + return OpenSSL::Random.random_bytes(n) + end + + # Implementation using system random device + def gen_random_urandom(n) + ret = Random.urandom(n) + unless ret + raise NotImplementedError, "No random device" + end + unless ret.length == n + raise NotImplementedError, "Unexpected partial read from random device: only #{ret.length} for #{n} bytes" + end + ret + end + + begin + # Check if Random.urandom is available + Random.urandom(1) + alias gen_random gen_random_urandom + rescue RuntimeError + begin + require 'openssl' + rescue NoMethodError + raise NotImplementedError, "No random device" + else + alias gen_random gen_random_openssl + end + end + + # :startdoc: + + # Generate random data bytes for Random::Formatter + public :gen_random + end +end + +Gem::SecureRandom.extend(Random::Formatter) diff --git a/lib/rubygems/vendor/timeout/lib/timeout.rb b/lib/rubygems/vendor/timeout/lib/timeout.rb new file mode 100644 index 0000000000..376b8c0e2b --- /dev/null +++ b/lib/rubygems/vendor/timeout/lib/timeout.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +# Timeout long-running blocks +# +# == Synopsis +# +# require 'rubygems/vendor/timeout/lib/timeout' +# status = Gem::Timeout.timeout(5) { +# # Something that should be interrupted if it takes more than 5 seconds... +# } +# +# == Description +# +# Gem::Timeout provides a way to auto-terminate a potentially long-running +# operation if it hasn't finished in a fixed amount of time. +# +# == Copyright +# +# Copyright:: (C) 2000 Network Applied Communication Laboratory, Inc. +# Copyright:: (C) 2000 Information-technology Promotion Agency, Japan + +module Gem::Timeout + # The version + VERSION = "0.4.4" + + # Internal error raised to when a timeout is triggered. + class ExitException < Exception + def exception(*) # :nodoc: + self + end + end + + # Raised by Gem::Timeout.timeout when the block times out. + class Error < RuntimeError + def self.handle_timeout(message) # :nodoc: + exc = ExitException.new(message) + + begin + yield exc + rescue ExitException => e + raise new(message) if exc.equal?(e) + raise + end + end + end + + # :stopdoc: + CONDVAR = ConditionVariable.new + QUEUE = Queue.new + QUEUE_MUTEX = Mutex.new + TIMEOUT_THREAD_MUTEX = Mutex.new + @timeout_thread = nil + private_constant :CONDVAR, :QUEUE, :QUEUE_MUTEX, :TIMEOUT_THREAD_MUTEX + + class Request + attr_reader :deadline + + def initialize(thread, timeout, exception_class, message) + @thread = thread + @deadline = GET_TIME.call(Process::CLOCK_MONOTONIC) + timeout + @exception_class = exception_class + @message = message + + @mutex = Mutex.new + @done = false # protected by @mutex + end + + def done? + @mutex.synchronize do + @done + end + end + + def expired?(now) + now >= @deadline + end + + def interrupt + @mutex.synchronize do + unless @done + @thread.raise @exception_class, @message + @done = true + end + end + end + + def finished + @mutex.synchronize do + @done = true + end + end + end + private_constant :Request + + def self.create_timeout_thread + watcher = Thread.new do + requests = [] + while true + until QUEUE.empty? and !requests.empty? # wait to have at least one request + req = QUEUE.pop + requests << req unless req.done? + end + closest_deadline = requests.min_by(&:deadline).deadline + + now = 0.0 + QUEUE_MUTEX.synchronize do + while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and QUEUE.empty? + CONDVAR.wait(QUEUE_MUTEX, closest_deadline - now) + end + end + + requests.each do |req| + req.interrupt if req.expired?(now) + end + requests.reject!(&:done?) + end + end + ThreadGroup::Default.add(watcher) unless watcher.group.enclosed? + watcher.name = "Gem::Timeout stdlib thread" + watcher.thread_variable_set(:"\0__detached_thread__", true) + watcher + end + private_class_method :create_timeout_thread + + def self.ensure_timeout_thread_created + unless @timeout_thread and @timeout_thread.alive? + # If the Mutex is already owned we are in a signal handler. + # In that case, just return and let the main thread create the @timeout_thread. + return if TIMEOUT_THREAD_MUTEX.owned? + TIMEOUT_THREAD_MUTEX.synchronize do + unless @timeout_thread and @timeout_thread.alive? + @timeout_thread = create_timeout_thread + end + end + end + end + + # We keep a private reference so that time mocking libraries won't break + # Gem::Timeout. + GET_TIME = Process.method(:clock_gettime) + private_constant :GET_TIME + + # :startdoc: + + # Perform an operation in a block, raising an error if it takes longer than + # +sec+ seconds to complete. + # + # +sec+:: Number of seconds to wait for the block to terminate. Any non-negative number + # or nil may be used, including Floats to specify fractional seconds. A + # value of 0 or +nil+ will execute the block without any timeout. + # Any negative number will raise an ArgumentError. + # +klass+:: Exception Class to raise if the block fails to terminate + # in +sec+ seconds. Omitting will use the default, Gem::Timeout::Error + # +message+:: Error message to raise with Exception Class. + # Omitting will use the default, "execution expired" + # + # Returns the result of the block *if* the block completed before + # +sec+ seconds, otherwise throws an exception, based on the value of +klass+. + # + # The exception thrown to terminate the given block cannot be rescued inside + # the block unless +klass+ is given explicitly. However, the block can use + # ensure to prevent the handling of the exception. For that reason, this + # method cannot be relied on to enforce timeouts for untrusted blocks. + # + # If a scheduler is defined, it will be used to handle the timeout by invoking + # Scheduler#timeout_after. + # + # Note that this is both a method of module Gem::Timeout, so you can <tt>include + # Gem::Timeout</tt> into your classes so they have a #timeout method, as well as + # a module method, so you can call it directly as Gem::Timeout.timeout(). + def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ + return yield(sec) if sec == nil or sec.zero? + raise ArgumentError, "Timeout sec must be a non-negative number" if 0 > sec + + message ||= "execution expired" + + if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after) + return scheduler.timeout_after(sec, klass || Error, message, &block) + end + + Gem::Timeout.ensure_timeout_thread_created + perform = Proc.new do |exc| + request = Request.new(Thread.current, sec, exc, message) + QUEUE_MUTEX.synchronize do + QUEUE << request + CONDVAR.signal + end + begin + return yield(sec) + ensure + request.finished + end + end + + if klass + perform.call(klass) + else + Error.handle_timeout(message, &perform) + end + end + module_function :timeout +end diff --git a/lib/rubygems/vendor/tsort/lib/tsort.rb b/lib/rubygems/vendor/tsort/lib/tsort.rb new file mode 100644 index 0000000000..9dd7c09521 --- /dev/null +++ b/lib/rubygems/vendor/tsort/lib/tsort.rb @@ -0,0 +1,455 @@ +# frozen_string_literal: true + +#-- +# tsort.rb - provides a module for topological sorting and strongly connected components. +#++ +# + +# +# Gem::TSort implements topological sorting using Tarjan's algorithm for +# strongly connected components. +# +# Gem::TSort is designed to be able to be used with any object which can be +# interpreted as a directed graph. +# +# Gem::TSort requires two methods to interpret an object as a graph, +# tsort_each_node and tsort_each_child. +# +# * tsort_each_node is used to iterate for all nodes over a graph. +# * tsort_each_child is used to iterate for child nodes of a given node. +# +# The equality of nodes are defined by eql? and hash since +# Gem::TSort uses Hash internally. +# +# == A Simple Example +# +# The following example demonstrates how to mix the Gem::TSort module into an +# existing class (in this case, Hash). Here, we're treating each key in +# the hash as a node in the graph, and so we simply alias the required +# #tsort_each_node method to Hash's #each_key method. For each key in the +# hash, the associated value is an array of the node's child nodes. This +# choice in turn leads to our implementation of the required #tsort_each_child +# method, which fetches the array of child nodes and then iterates over that +# array using the user-supplied block. +# +# require 'rubygems/vendor/tsort/lib/tsort' +# +# class Hash +# include Gem::TSort +# alias tsort_each_node each_key +# def tsort_each_child(node, &block) +# fetch(node).each(&block) +# end +# end +# +# {1=>[2, 3], 2=>[3], 3=>[], 4=>[]}.tsort +# #=> [3, 2, 1, 4] +# +# {1=>[2], 2=>[3, 4], 3=>[2], 4=>[]}.strongly_connected_components +# #=> [[4], [2, 3], [1]] +# +# == A More Realistic Example +# +# A very simple `make' like tool can be implemented as follows: +# +# require 'rubygems/vendor/tsort/lib/tsort' +# +# class Make +# def initialize +# @dep = {} +# @dep.default = [] +# end +# +# def rule(outputs, inputs=[], &block) +# triple = [outputs, inputs, block] +# outputs.each {|f| @dep[f] = [triple]} +# @dep[triple] = inputs +# end +# +# def build(target) +# each_strongly_connected_component_from(target) {|ns| +# if ns.length != 1 +# fs = ns.delete_if {|n| Array === n} +# raise Gem::TSort::Cyclic.new("cyclic dependencies: #{fs.join ', '}") +# end +# n = ns.first +# if Array === n +# outputs, inputs, block = n +# inputs_time = inputs.map {|f| File.mtime f}.max +# begin +# outputs_time = outputs.map {|f| File.mtime f}.min +# rescue Errno::ENOENT +# outputs_time = nil +# end +# if outputs_time == nil || +# inputs_time != nil && outputs_time <= inputs_time +# sleep 1 if inputs_time != nil && inputs_time.to_i == Time.now.to_i +# block.call +# end +# end +# } +# end +# +# def tsort_each_child(node, &block) +# @dep[node].each(&block) +# end +# include Gem::TSort +# end +# +# def command(arg) +# print arg, "\n" +# system arg +# end +# +# m = Make.new +# m.rule(%w[t1]) { command 'date > t1' } +# m.rule(%w[t2]) { command 'date > t2' } +# m.rule(%w[t3]) { command 'date > t3' } +# m.rule(%w[t4], %w[t1 t3]) { command 'cat t1 t3 > t4' } +# m.rule(%w[t5], %w[t4 t2]) { command 'cat t4 t2 > t5' } +# m.build('t5') +# +# == Bugs +# +# * 'tsort.rb' is wrong name because this library uses +# Tarjan's algorithm for strongly connected components. +# Although 'strongly_connected_components.rb' is correct but too long. +# +# == References +# +# R. E. Tarjan, "Depth First Search and Linear Graph Algorithms", +# <em>SIAM Journal on Computing</em>, Vol. 1, No. 2, pp. 146-160, June 1972. +# + +module Gem::TSort + + VERSION = "0.2.0" + + class Cyclic < StandardError + end + + # Returns a topologically sorted array of nodes. + # The array is sorted from children to parents, i.e. + # the first element has no child and the last node has no parent. + # + # If there is a cycle, Gem::TSort::Cyclic is raised. + # + # class G + # include Gem::TSort + # def initialize(g) + # @g = g + # end + # def tsort_each_child(n, &b) @g[n].each(&b) end + # def tsort_each_node(&b) @g.each_key(&b) end + # end + # + # graph = G.new({1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]}) + # p graph.tsort #=> [4, 2, 3, 1] + # + # graph = G.new({1=>[2], 2=>[3, 4], 3=>[2], 4=>[]}) + # p graph.tsort # raises Gem::TSort::Cyclic + # + def tsort + each_node = method(:tsort_each_node) + each_child = method(:tsort_each_child) + Gem::TSort.tsort(each_node, each_child) + end + + # Returns a topologically sorted array of nodes. + # The array is sorted from children to parents, i.e. + # the first element has no child and the last node has no parent. + # + # The graph is represented by _each_node_ and _each_child_. + # _each_node_ should have +call+ method which yields for each node in the graph. + # _each_child_ should have +call+ method which takes a node argument and yields for each child node. + # + # If there is a cycle, Gem::TSort::Cyclic is raised. + # + # g = {1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # p Gem::TSort.tsort(each_node, each_child) #=> [4, 2, 3, 1] + # + # g = {1=>[2], 2=>[3, 4], 3=>[2], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # p Gem::TSort.tsort(each_node, each_child) # raises Gem::TSort::Cyclic + # + def self.tsort(each_node, each_child) + tsort_each(each_node, each_child).to_a + end + + # The iterator version of the #tsort method. + # <tt><em>obj</em>.tsort_each</tt> is similar to <tt><em>obj</em>.tsort.each</tt>, but + # modification of _obj_ during the iteration may lead to unexpected results. + # + # #tsort_each returns +nil+. + # If there is a cycle, Gem::TSort::Cyclic is raised. + # + # class G + # include Gem::TSort + # def initialize(g) + # @g = g + # end + # def tsort_each_child(n, &b) @g[n].each(&b) end + # def tsort_each_node(&b) @g.each_key(&b) end + # end + # + # graph = G.new({1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]}) + # graph.tsort_each {|n| p n } + # #=> 4 + # # 2 + # # 3 + # # 1 + # + def tsort_each(&block) # :yields: node + each_node = method(:tsort_each_node) + each_child = method(:tsort_each_child) + Gem::TSort.tsort_each(each_node, each_child, &block) + end + + # The iterator version of the Gem::TSort.tsort method. + # + # The graph is represented by _each_node_ and _each_child_. + # _each_node_ should have +call+ method which yields for each node in the graph. + # _each_child_ should have +call+ method which takes a node argument and yields for each child node. + # + # g = {1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # Gem::TSort.tsort_each(each_node, each_child) {|n| p n } + # #=> 4 + # # 2 + # # 3 + # # 1 + # + def self.tsort_each(each_node, each_child) # :yields: node + return to_enum(__method__, each_node, each_child) unless block_given? + + each_strongly_connected_component(each_node, each_child) {|component| + if component.size == 1 + yield component.first + else + raise Cyclic.new("topological sort failed: #{component.inspect}") + end + } + end + + # Returns strongly connected components as an array of arrays of nodes. + # The array is sorted from children to parents. + # Each elements of the array represents a strongly connected component. + # + # class G + # include Gem::TSort + # def initialize(g) + # @g = g + # end + # def tsort_each_child(n, &b) @g[n].each(&b) end + # def tsort_each_node(&b) @g.each_key(&b) end + # end + # + # graph = G.new({1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]}) + # p graph.strongly_connected_components #=> [[4], [2], [3], [1]] + # + # graph = G.new({1=>[2], 2=>[3, 4], 3=>[2], 4=>[]}) + # p graph.strongly_connected_components #=> [[4], [2, 3], [1]] + # + def strongly_connected_components + each_node = method(:tsort_each_node) + each_child = method(:tsort_each_child) + Gem::TSort.strongly_connected_components(each_node, each_child) + end + + # Returns strongly connected components as an array of arrays of nodes. + # The array is sorted from children to parents. + # Each elements of the array represents a strongly connected component. + # + # The graph is represented by _each_node_ and _each_child_. + # _each_node_ should have +call+ method which yields for each node in the graph. + # _each_child_ should have +call+ method which takes a node argument and yields for each child node. + # + # g = {1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # p Gem::TSort.strongly_connected_components(each_node, each_child) + # #=> [[4], [2], [3], [1]] + # + # g = {1=>[2], 2=>[3, 4], 3=>[2], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # p Gem::TSort.strongly_connected_components(each_node, each_child) + # #=> [[4], [2, 3], [1]] + # + def self.strongly_connected_components(each_node, each_child) + each_strongly_connected_component(each_node, each_child).to_a + end + + # The iterator version of the #strongly_connected_components method. + # <tt><em>obj</em>.each_strongly_connected_component</tt> is similar to + # <tt><em>obj</em>.strongly_connected_components.each</tt>, but + # modification of _obj_ during the iteration may lead to unexpected results. + # + # #each_strongly_connected_component returns +nil+. + # + # class G + # include Gem::TSort + # def initialize(g) + # @g = g + # end + # def tsort_each_child(n, &b) @g[n].each(&b) end + # def tsort_each_node(&b) @g.each_key(&b) end + # end + # + # graph = G.new({1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]}) + # graph.each_strongly_connected_component {|scc| p scc } + # #=> [4] + # # [2] + # # [3] + # # [1] + # + # graph = G.new({1=>[2], 2=>[3, 4], 3=>[2], 4=>[]}) + # graph.each_strongly_connected_component {|scc| p scc } + # #=> [4] + # # [2, 3] + # # [1] + # + def each_strongly_connected_component(&block) # :yields: nodes + each_node = method(:tsort_each_node) + each_child = method(:tsort_each_child) + Gem::TSort.each_strongly_connected_component(each_node, each_child, &block) + end + + # The iterator version of the Gem::TSort.strongly_connected_components method. + # + # The graph is represented by _each_node_ and _each_child_. + # _each_node_ should have +call+ method which yields for each node in the graph. + # _each_child_ should have +call+ method which takes a node argument and yields for each child node. + # + # g = {1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # Gem::TSort.each_strongly_connected_component(each_node, each_child) {|scc| p scc } + # #=> [4] + # # [2] + # # [3] + # # [1] + # + # g = {1=>[2], 2=>[3, 4], 3=>[2], 4=>[]} + # each_node = lambda {|&b| g.each_key(&b) } + # each_child = lambda {|n, &b| g[n].each(&b) } + # Gem::TSort.each_strongly_connected_component(each_node, each_child) {|scc| p scc } + # #=> [4] + # # [2, 3] + # # [1] + # + def self.each_strongly_connected_component(each_node, each_child) # :yields: nodes + return to_enum(__method__, each_node, each_child) unless block_given? + + id_map = {} + stack = [] + each_node.call {|node| + unless id_map.include? node + each_strongly_connected_component_from(node, each_child, id_map, stack) {|c| + yield c + } + end + } + nil + end + + # Iterates over strongly connected component in the subgraph reachable from + # _node_. + # + # Return value is unspecified. + # + # #each_strongly_connected_component_from doesn't call #tsort_each_node. + # + # class G + # include Gem::TSort + # def initialize(g) + # @g = g + # end + # def tsort_each_child(n, &b) @g[n].each(&b) end + # def tsort_each_node(&b) @g.each_key(&b) end + # end + # + # graph = G.new({1=>[2, 3], 2=>[4], 3=>[2, 4], 4=>[]}) + # graph.each_strongly_connected_component_from(2) {|scc| p scc } + # #=> [4] + # # [2] + # + # graph = G.new({1=>[2], 2=>[3, 4], 3=>[2], 4=>[]}) + # graph.each_strongly_connected_component_from(2) {|scc| p scc } + # #=> [4] + # # [2, 3] + # + def each_strongly_connected_component_from(node, id_map={}, stack=[], &block) # :yields: nodes + Gem::TSort.each_strongly_connected_component_from(node, method(:tsort_each_child), id_map, stack, &block) + end + + # Iterates over strongly connected components in a graph. + # The graph is represented by _node_ and _each_child_. + # + # _node_ is the first node. + # _each_child_ should have +call+ method which takes a node argument + # and yields for each child node. + # + # Return value is unspecified. + # + # #Gem::TSort.each_strongly_connected_component_from is a class method and + # it doesn't need a class to represent a graph which includes Gem::TSort. + # + # graph = {1=>[2], 2=>[3, 4], 3=>[2], 4=>[]} + # each_child = lambda {|n, &b| graph[n].each(&b) } + # Gem::TSort.each_strongly_connected_component_from(1, each_child) {|scc| + # p scc + # } + # #=> [4] + # # [2, 3] + # # [1] + # + def self.each_strongly_connected_component_from(node, each_child, id_map={}, stack=[]) # :yields: nodes + return to_enum(__method__, node, each_child, id_map, stack) unless block_given? + + minimum_id = node_id = id_map[node] = id_map.size + stack_length = stack.length + stack << node + + each_child.call(node) {|child| + if id_map.include? child + child_id = id_map[child] + minimum_id = child_id if child_id && child_id < minimum_id + else + sub_minimum_id = + each_strongly_connected_component_from(child, each_child, id_map, stack) {|c| + yield c + } + minimum_id = sub_minimum_id if sub_minimum_id < minimum_id + end + } + + if node_id == minimum_id + component = stack.slice!(stack_length .. -1) + component.each {|n| id_map[n] = nil} + yield component + end + + minimum_id + end + + # Should be implemented by a extended class. + # + # #tsort_each_node is used to iterate for all nodes over a graph. + # + def tsort_each_node # :yields: node + raise NotImplementedError.new + end + + # Should be implemented by a extended class. + # + # #tsort_each_child is used to iterate for child nodes of _node_. + # + def tsort_each_child(node) # :yields: child + raise NotImplementedError.new + end +end diff --git a/lib/rubygems/vendor/uri/lib/uri.rb b/lib/rubygems/vendor/uri/lib/uri.rb new file mode 100644 index 0000000000..4691b122b2 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: false +# Gem::URI is a module providing classes to handle Uniform Resource Identifiers +# (RFC2396[https://www.rfc-editor.org/rfc/rfc2396]). +# +# == Features +# +# * Uniform way of handling URIs. +# * Flexibility to introduce custom Gem::URI schemes. +# * Flexibility to have an alternate Gem::URI::Parser (or just different patterns +# and regexp's). +# +# == Basic example +# +# require 'rubygems/vendor/uri/lib/uri' +# +# uri = Gem::URI("http://foo.com/posts?id=30&limit=5#time=1305298413") +# #=> #<Gem::URI::HTTP http://foo.com/posts?id=30&limit=5#time=1305298413> +# +# uri.scheme #=> "http" +# uri.host #=> "foo.com" +# uri.path #=> "/posts" +# uri.query #=> "id=30&limit=5" +# uri.fragment #=> "time=1305298413" +# +# uri.to_s #=> "http://foo.com/posts?id=30&limit=5#time=1305298413" +# +# == Adding custom URIs +# +# module Gem::URI +# class RSYNC < Generic +# DEFAULT_PORT = 873 +# end +# register_scheme 'RSYNC', RSYNC +# end +# #=> Gem::URI::RSYNC +# +# Gem::URI.scheme_list +# #=> {"FILE"=>Gem::URI::File, "FTP"=>Gem::URI::FTP, "HTTP"=>Gem::URI::HTTP, +# # "HTTPS"=>Gem::URI::HTTPS, "LDAP"=>Gem::URI::LDAP, "LDAPS"=>Gem::URI::LDAPS, +# # "MAILTO"=>Gem::URI::MailTo, "RSYNC"=>Gem::URI::RSYNC} +# +# uri = Gem::URI("rsync://rsync.foo.com") +# #=> #<Gem::URI::RSYNC rsync://rsync.foo.com> +# +# == RFC References +# +# A good place to view an RFC spec is http://www.ietf.org/rfc.html. +# +# Here is a list of all related RFC's: +# - RFC822[https://www.rfc-editor.org/rfc/rfc822] +# - RFC1738[https://www.rfc-editor.org/rfc/rfc1738] +# - RFC2255[https://www.rfc-editor.org/rfc/rfc2255] +# - RFC2368[https://www.rfc-editor.org/rfc/rfc2368] +# - RFC2373[https://www.rfc-editor.org/rfc/rfc2373] +# - RFC2396[https://www.rfc-editor.org/rfc/rfc2396] +# - RFC2732[https://www.rfc-editor.org/rfc/rfc2732] +# - RFC3986[https://www.rfc-editor.org/rfc/rfc3986] +# +# == Class tree +# +# - Gem::URI::Generic (in uri/generic.rb) +# - Gem::URI::File - (in uri/file.rb) +# - Gem::URI::FTP - (in uri/ftp.rb) +# - Gem::URI::HTTP - (in uri/http.rb) +# - Gem::URI::HTTPS - (in uri/https.rb) +# - Gem::URI::LDAP - (in uri/ldap.rb) +# - Gem::URI::LDAPS - (in uri/ldaps.rb) +# - Gem::URI::MailTo - (in uri/mailto.rb) +# - Gem::URI::Parser - (in uri/common.rb) +# - Gem::URI::REGEXP - (in uri/common.rb) +# - Gem::URI::REGEXP::PATTERN - (in uri/common.rb) +# - Gem::URI::Util - (in uri/common.rb) +# - Gem::URI::Error - (in uri/common.rb) +# - Gem::URI::InvalidURIError - (in uri/common.rb) +# - Gem::URI::InvalidComponentError - (in uri/common.rb) +# - Gem::URI::BadURIError - (in uri/common.rb) +# +# == Copyright Info +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# Documentation:: +# Akira Yamada <akira@ruby-lang.org> +# Dmitry V. Sabanin <sdmitry@lrn.ru> +# Vincent Batts <vbatts@hashbangbash.com> +# License:: +# Copyright (c) 2001 akira yamada <akira@ruby-lang.org> +# You can redistribute it and/or modify it under the same term as Ruby. +# + +module Gem::URI +end + +require_relative 'uri/version' +require_relative 'uri/common' +require_relative 'uri/generic' +require_relative 'uri/file' +require_relative 'uri/ftp' +require_relative 'uri/http' +require_relative 'uri/https' +require_relative 'uri/ldap' +require_relative 'uri/ldaps' +require_relative 'uri/mailto' +require_relative 'uri/ws' +require_relative 'uri/wss' diff --git a/lib/rubygems/vendor/uri/lib/uri/common.rb b/lib/rubygems/vendor/uri/lib/uri/common.rb new file mode 100644 index 0000000000..e9bdfa6a07 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/common.rb @@ -0,0 +1,922 @@ +# frozen_string_literal: true +#-- +# = uri/common.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: +# You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative "rfc2396_parser" +require_relative "rfc3986_parser" + +module Gem::URI + # The default parser instance for RFC 2396. + RFC2396_PARSER = RFC2396_Parser.new + Ractor.make_shareable(RFC2396_PARSER) if defined?(Ractor) + + # The default parser instance for RFC 3986. + RFC3986_PARSER = RFC3986_Parser.new + Ractor.make_shareable(RFC3986_PARSER) if defined?(Ractor) + + # The default parser instance. + DEFAULT_PARSER = RFC3986_PARSER + Ractor.make_shareable(DEFAULT_PARSER) if defined?(Ractor) + + # Set the default parser instance. + def self.parser=(parser = RFC3986_PARSER) + remove_const(:Parser) if defined?(::Gem::URI::Parser) + const_set("Parser", parser.class) + + remove_const(:PARSER) if defined?(::Gem::URI::PARSER) + const_set("PARSER", parser) + + remove_const(:REGEXP) if defined?(::Gem::URI::REGEXP) + remove_const(:PATTERN) if defined?(::Gem::URI::PATTERN) + if Parser == RFC2396_Parser + const_set("REGEXP", Gem::URI::RFC2396_REGEXP) + const_set("PATTERN", Gem::URI::RFC2396_REGEXP::PATTERN) + end + + Parser.new.regexp.each_pair do |sym, str| + remove_const(sym) if const_defined?(sym, false) + const_set(sym, str) + end + end + self.parser = RFC3986_PARSER + + def self.const_missing(const) # :nodoc: + if const == :REGEXP + warn "Gem::URI::REGEXP is obsolete. Use Gem::URI::RFC2396_REGEXP explicitly.", uplevel: 1 if $VERBOSE + Gem::URI::RFC2396_REGEXP + elsif value = RFC2396_PARSER.regexp[const] + warn "Gem::URI::#{const} is obsolete. Use Gem::URI::RFC2396_PARSER.regexp[#{const.inspect}] explicitly.", uplevel: 1 if $VERBOSE + value + elsif value = RFC2396_Parser.const_get(const) + warn "Gem::URI::#{const} is obsolete. Use Gem::URI::RFC2396_Parser::#{const} explicitly.", uplevel: 1 if $VERBOSE + value + else + super + end + end + + module Util # :nodoc: + def make_components_hash(klass, array_hash) + tmp = {} + if array_hash.kind_of?(Array) && + array_hash.size == klass.component.size - 1 + klass.component[1..-1].each_index do |i| + begin + tmp[klass.component[i + 1]] = array_hash[i].clone + rescue TypeError + tmp[klass.component[i + 1]] = array_hash[i] + end + end + + elsif array_hash.kind_of?(Hash) + array_hash.each do |key, value| + begin + tmp[key] = value.clone + rescue TypeError + tmp[key] = value + end + end + else + raise ArgumentError, + "expected Array of or Hash of components of #{klass} (#{klass.component[1..-1].join(', ')})" + end + tmp[:scheme] = klass.to_s.sub(/\A.*::/, '').downcase + + return tmp + end + module_function :make_components_hash + end + + module Schemes # :nodoc: + class << self + ReservedChars = ".+-" + EscapedChars = "\u01C0\u01C1\u01C2" + # Use Lo category chars as escaped chars for TruffleRuby, which + # does not allow Symbol categories as identifiers. + + def escape(name) + unless name and name.ascii_only? + return nil + end + name.upcase.tr(ReservedChars, EscapedChars) + end + + def unescape(name) + name.tr(EscapedChars, ReservedChars).encode(Encoding::US_ASCII).upcase + end + + def find(name) + const_get(name, false) if name and const_defined?(name, false) + end + + def register(name, klass) + unless scheme = escape(name) + raise ArgumentError, "invalid character as scheme - #{name}" + end + const_set(scheme, klass) + end + + def list + constants.map { |name| + [unescape(name.to_s), const_get(name)] + }.to_h + end + end + end + private_constant :Schemes + + # Registers the given +klass+ as the class to be instantiated + # when parsing a \Gem::URI with the given +scheme+: + # + # Gem::URI.register_scheme('MS_SEARCH', Gem::URI::Generic) # => Gem::URI::Generic + # Gem::URI.scheme_list['MS_SEARCH'] # => Gem::URI::Generic + # + # Note that after calling String#upcase on +scheme+, it must be a valid + # constant name. + def self.register_scheme(scheme, klass) + Schemes.register(scheme, klass) + end + + # Returns a hash of the defined schemes: + # + # Gem::URI.scheme_list + # # => + # {"MAILTO"=>Gem::URI::MailTo, + # "LDAPS"=>Gem::URI::LDAPS, + # "WS"=>Gem::URI::WS, + # "HTTP"=>Gem::URI::HTTP, + # "HTTPS"=>Gem::URI::HTTPS, + # "LDAP"=>Gem::URI::LDAP, + # "FILE"=>Gem::URI::File, + # "FTP"=>Gem::URI::FTP} + # + # Related: Gem::URI.register_scheme. + def self.scheme_list + Schemes.list + end + + # :stopdoc: + INITIAL_SCHEMES = scheme_list + private_constant :INITIAL_SCHEMES + Ractor.make_shareable(INITIAL_SCHEMES) if defined?(Ractor) + # :startdoc: + + # Returns a new object constructed from the given +scheme+, +arguments+, + # and +default+: + # + # - The new object is an instance of <tt>Gem::URI.scheme_list[scheme.upcase]</tt>. + # - The object is initialized by calling the class initializer + # using +scheme+ and +arguments+. + # See Gem::URI::Generic.new. + # + # Examples: + # + # values = ['john.doe', 'www.example.com', '123', nil, '/forum/questions/', nil, 'tag=networking&order=newest', 'top'] + # Gem::URI.for('https', *values) + # # => #<Gem::URI::HTTPS https://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top> + # Gem::URI.for('foo', *values, default: Gem::URI::HTTP) + # # => #<Gem::URI::HTTP foo://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top> + # + def self.for(scheme, *arguments, default: Generic) + const_name = Schemes.escape(scheme) + + uri_class = INITIAL_SCHEMES[const_name] + uri_class ||= Schemes.find(const_name) + uri_class ||= default + + return uri_class.new(scheme, *arguments) + end + + # + # Base class for all Gem::URI exceptions. + # + class Error < StandardError; end + # + # Not a Gem::URI. + # + class InvalidURIError < Error; end + # + # Not a Gem::URI component. + # + class InvalidComponentError < Error; end + # + # Gem::URI is valid, bad usage is not. + # + class BadURIError < Error; end + + # Returns a 9-element array representing the parts of the \Gem::URI + # formed from the string +uri+; + # each array element is a string or +nil+: + # + # names = %w[scheme userinfo host port registry path opaque query fragment] + # values = Gem::URI.split('https://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top') + # names.zip(values) + # # => + # [["scheme", "https"], + # ["userinfo", "john.doe"], + # ["host", "www.example.com"], + # ["port", "123"], + # ["registry", nil], + # ["path", "/forum/questions/"], + # ["opaque", nil], + # ["query", "tag=networking&order=newest"], + # ["fragment", "top"]] + # + def self.split(uri) + PARSER.split(uri) + end + + # Returns a new \Gem::URI object constructed from the given string +uri+: + # + # Gem::URI.parse('https://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top') + # # => #<Gem::URI::HTTPS https://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top> + # Gem::URI.parse('http://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top') + # # => #<Gem::URI::HTTP http://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top> + # + # It's recommended to first Gem::URI::RFC2396_PARSER.escape string +uri+ + # if it may contain invalid Gem::URI characters. + # + def self.parse(uri) + PARSER.parse(uri) + end + + # Merges the given Gem::URI strings +str+ + # per {RFC 2396}[https://www.rfc-editor.org/rfc/rfc2396.html]. + # + # Each string in +str+ is converted to an + # {RFC3986 Gem::URI}[https://www.rfc-editor.org/rfc/rfc3986.html] before being merged. + # + # Examples: + # + # Gem::URI.join("http://example.com/","main.rbx") + # # => #<Gem::URI::HTTP http://example.com/main.rbx> + # + # Gem::URI.join('http://example.com', 'foo') + # # => #<Gem::URI::HTTP http://example.com/foo> + # + # Gem::URI.join('http://example.com', '/foo', '/bar') + # # => #<Gem::URI::HTTP http://example.com/bar> + # + # Gem::URI.join('http://example.com', '/foo', 'bar') + # # => #<Gem::URI::HTTP http://example.com/bar> + # + # Gem::URI.join('http://example.com', '/foo/', 'bar') + # # => #<Gem::URI::HTTP http://example.com/foo/bar> + # + def self.join(*str) + DEFAULT_PARSER.join(*str) + end + + # + # == Synopsis + # + # Gem::URI::extract(str[, schemes][,&blk]) + # + # == Args + # + # +str+:: + # String to extract URIs from. + # +schemes+:: + # Limit Gem::URI matching to specific schemes. + # + # == Description + # + # Extracts URIs from a string. If block given, iterates through all matched URIs. + # Returns nil if block given or array with matches. + # + # == Usage + # + # require "rubygems/vendor/uri/lib/uri" + # + # Gem::URI.extract("text here http://foo.example.org/bla and here mailto:test@example.com and here also.") + # # => ["http://foo.example.com/bla", "mailto:test@example.com"] + # + def self.extract(str, schemes = nil, &block) # :nodoc: + warn "Gem::URI.extract is obsolete", uplevel: 1 if $VERBOSE + PARSER.extract(str, schemes, &block) + end + + # + # == Synopsis + # + # Gem::URI::regexp([match_schemes]) + # + # == Args + # + # +match_schemes+:: + # Array of schemes. If given, resulting regexp matches to URIs + # whose scheme is one of the match_schemes. + # + # == Description + # + # Returns a Regexp object which matches to Gem::URI-like strings. + # The Regexp object returned by this method includes arbitrary + # number of capture group (parentheses). Never rely on its number. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # # extract first Gem::URI from html_string + # html_string.slice(Gem::URI.regexp) + # + # # remove ftp URIs + # html_string.sub(Gem::URI.regexp(['ftp']), '') + # + # # You should not rely on the number of parentheses + # html_string.scan(Gem::URI.regexp) do |*matches| + # p $& + # end + # + def self.regexp(schemes = nil)# :nodoc: + warn "Gem::URI.regexp is obsolete", uplevel: 1 if $VERBOSE + PARSER.make_regexp(schemes) + end + + TBLENCWWWCOMP_ = {} # :nodoc: + 256.times do |i| + TBLENCWWWCOMP_[-i.chr] = -('%%%02X' % i) + end + TBLENCURICOMP_ = TBLENCWWWCOMP_.dup.freeze # :nodoc: + TBLENCWWWCOMP_[' '] = '+' + TBLENCWWWCOMP_.freeze + TBLDECWWWCOMP_ = {} # :nodoc: + 256.times do |i| + h, l = i>>4, i&15 + TBLDECWWWCOMP_[-('%%%X%X' % [h, l])] = -i.chr + TBLDECWWWCOMP_[-('%%%x%X' % [h, l])] = -i.chr + TBLDECWWWCOMP_[-('%%%X%x' % [h, l])] = -i.chr + TBLDECWWWCOMP_[-('%%%x%x' % [h, l])] = -i.chr + end + TBLDECWWWCOMP_['+'] = ' ' + TBLDECWWWCOMP_.freeze + + # Returns a URL-encoded string derived from the given string +str+. + # + # The returned string: + # + # - Preserves: + # + # - Characters <tt>'*'</tt>, <tt>'.'</tt>, <tt>'-'</tt>, and <tt>'_'</tt>. + # - Character in ranges <tt>'a'..'z'</tt>, <tt>'A'..'Z'</tt>, + # and <tt>'0'..'9'</tt>. + # + # Example: + # + # Gem::URI.encode_www_form_component('*.-_azAZ09') + # # => "*.-_azAZ09" + # + # - Converts: + # + # - Character <tt>' '</tt> to character <tt>'+'</tt>. + # - Any other character to "percent notation"; + # the percent notation for character <i>c</i> is <tt>'%%%X' % c.ord</tt>. + # + # Example: + # + # Gem::URI.encode_www_form_component('Here are some punctuation characters: ,;?:') + # # => "Here+are+some+punctuation+characters%3A+%2C%3B%3F%3A" + # + # Encoding: + # + # - If +str+ has encoding Encoding::ASCII_8BIT, argument +enc+ is ignored. + # - Otherwise +str+ is converted first to Encoding::UTF_8 + # (with suitable character replacements), + # and then to encoding +enc+. + # + # In either case, the returned string has forced encoding Encoding::US_ASCII. + # + # Related: Gem::URI.encode_uri_component (encodes <tt>' '</tt> as <tt>'%20'</tt>). + def self.encode_www_form_component(str, enc=nil) + _encode_uri_component(/[^*\-.0-9A-Z_a-z]/, TBLENCWWWCOMP_, str, enc) + end + + # Returns a string decoded from the given \URL-encoded string +str+. + # + # The given string is first encoded as Encoding::ASCII-8BIT (using String#b), + # then decoded (as below), and finally force-encoded to the given encoding +enc+. + # + # The returned string: + # + # - Preserves: + # + # - Characters <tt>'*'</tt>, <tt>'.'</tt>, <tt>'-'</tt>, and <tt>'_'</tt>. + # - Character in ranges <tt>'a'..'z'</tt>, <tt>'A'..'Z'</tt>, + # and <tt>'0'..'9'</tt>. + # + # Example: + # + # Gem::URI.decode_www_form_component('*.-_azAZ09') + # # => "*.-_azAZ09" + # + # - Converts: + # + # - Character <tt>'+'</tt> to character <tt>' '</tt>. + # - Each "percent notation" to an ASCII character. + # + # Example: + # + # Gem::URI.decode_www_form_component('Here+are+some+punctuation+characters%3A+%2C%3B%3F%3A') + # # => "Here are some punctuation characters: ,;?:" + # + # Related: Gem::URI.decode_uri_component (preserves <tt>'+'</tt>). + def self.decode_www_form_component(str, enc=Encoding::UTF_8) + _decode_uri_component(/\+|%\h\h/, str, enc) + end + + # Like Gem::URI.encode_www_form_component, except that <tt>' '</tt> (space) + # is encoded as <tt>'%20'</tt> (instead of <tt>'+'</tt>). + def self.encode_uri_component(str, enc=nil) + _encode_uri_component(/[^*\-.0-9A-Z_a-z]/, TBLENCURICOMP_, str, enc) + end + + # Like Gem::URI.decode_www_form_component, except that <tt>'+'</tt> is preserved. + def self.decode_uri_component(str, enc=Encoding::UTF_8) + _decode_uri_component(/%\h\h/, str, enc) + end + + # Returns a string derived from the given string +str+ with + # Gem::URI-encoded characters matching +regexp+ according to +table+. + def self._encode_uri_component(regexp, table, str, enc) + str = str.to_s.dup + if str.encoding != Encoding::ASCII_8BIT + if enc && enc != Encoding::ASCII_8BIT + str.encode!(Encoding::UTF_8, invalid: :replace, undef: :replace) + str.encode!(enc, fallback: ->(x){"&##{x.ord};"}) + end + str.force_encoding(Encoding::ASCII_8BIT) + end + str.gsub!(regexp, table) + str.force_encoding(Encoding::US_ASCII) + end + private_class_method :_encode_uri_component + + # Returns a string decoding characters matching +regexp+ from the + # given \URL-encoded string +str+. + def self._decode_uri_component(regexp, str, enc) + raise ArgumentError, "invalid %-encoding (#{str})" if /%(?!\h\h)/.match?(str) + str.b.gsub(regexp, TBLDECWWWCOMP_).force_encoding(enc) + end + private_class_method :_decode_uri_component + + # Returns a URL-encoded string derived from the given + # {Enumerable}[rdoc-ref:Enumerable@Enumerable+in+Ruby+Classes] + # +enum+. + # + # The result is suitable for use as form data + # for an \HTTP request whose <tt>Content-Type</tt> is + # <tt>'application/x-www-form-urlencoded'</tt>. + # + # The returned string consists of the elements of +enum+, + # each converted to one or more URL-encoded strings, + # and all joined with character <tt>'&'</tt>. + # + # Simple examples: + # + # Gem::URI.encode_www_form([['foo', 0], ['bar', 1], ['baz', 2]]) + # # => "foo=0&bar=1&baz=2" + # Gem::URI.encode_www_form({foo: 0, bar: 1, baz: 2}) + # # => "foo=0&bar=1&baz=2" + # + # The returned string is formed using method Gem::URI.encode_www_form_component, + # which converts certain characters: + # + # Gem::URI.encode_www_form('f#o': '/', 'b-r': '$', 'b z': '@') + # # => "f%23o=%2F&b-r=%24&b+z=%40" + # + # When +enum+ is Array-like, each element +ele+ is converted to a field: + # + # - If +ele+ is an array of two or more elements, + # the field is formed from its first two elements + # (and any additional elements are ignored): + # + # name = Gem::URI.encode_www_form_component(ele[0], enc) + # value = Gem::URI.encode_www_form_component(ele[1], enc) + # "#{name}=#{value}" + # + # Examples: + # + # Gem::URI.encode_www_form([%w[foo bar], %w[baz bat bah]]) + # # => "foo=bar&baz=bat" + # Gem::URI.encode_www_form([['foo', 0], ['bar', :baz, 'bat']]) + # # => "foo=0&bar=baz" + # + # - If +ele+ is an array of one element, + # the field is formed from <tt>ele[0]</tt>: + # + # Gem::URI.encode_www_form_component(ele[0]) + # + # Example: + # + # Gem::URI.encode_www_form([['foo'], [:bar], [0]]) + # # => "foo&bar&0" + # + # - Otherwise the field is formed from +ele+: + # + # Gem::URI.encode_www_form_component(ele) + # + # Example: + # + # Gem::URI.encode_www_form(['foo', :bar, 0]) + # # => "foo&bar&0" + # + # The elements of an Array-like +enum+ may be mixture: + # + # Gem::URI.encode_www_form([['foo', 0], ['bar', 1, 2], ['baz'], :bat]) + # # => "foo=0&bar=1&baz&bat" + # + # When +enum+ is Hash-like, + # each +key+/+value+ pair is converted to one or more fields: + # + # - If +value+ is + # {Array-convertible}[rdoc-ref:implicit_conversion.rdoc@Array-Convertible+Objects], + # each element +ele+ in +value+ is paired with +key+ to form a field: + # + # name = Gem::URI.encode_www_form_component(key, enc) + # value = Gem::URI.encode_www_form_component(ele, enc) + # "#{name}=#{value}" + # + # Example: + # + # Gem::URI.encode_www_form({foo: [:bar, 1], baz: [:bat, :bam, 2]}) + # # => "foo=bar&foo=1&baz=bat&baz=bam&baz=2" + # + # - Otherwise, +key+ and +value+ are paired to form a field: + # + # name = Gem::URI.encode_www_form_component(key, enc) + # value = Gem::URI.encode_www_form_component(value, enc) + # "#{name}=#{value}" + # + # Example: + # + # Gem::URI.encode_www_form({foo: 0, bar: 1, baz: 2}) + # # => "foo=0&bar=1&baz=2" + # + # The elements of a Hash-like +enum+ may be mixture: + # + # Gem::URI.encode_www_form({foo: [0, 1], bar: 2}) + # # => "foo=0&foo=1&bar=2" + # + def self.encode_www_form(enum, enc=nil) + enum.map do |k,v| + if v.nil? + encode_www_form_component(k, enc) + elsif v.respond_to?(:to_ary) + v.to_ary.map do |w| + str = encode_www_form_component(k, enc) + unless w.nil? + str << '=' + str << encode_www_form_component(w, enc) + end + end.join('&') + else + str = encode_www_form_component(k, enc) + str << '=' + str << encode_www_form_component(v, enc) + end + end.join('&') + end + + # Returns name/value pairs derived from the given string +str+, + # which must be an ASCII string. + # + # The method may be used to decode the body of Net::HTTPResponse object +res+ + # for which <tt>res['Content-Type']</tt> is <tt>'application/x-www-form-urlencoded'</tt>. + # + # The returned data is an array of 2-element subarrays; + # each subarray is a name/value pair (both are strings). + # Each returned string has encoding +enc+, + # and has had invalid characters removed via + # {String#scrub}[rdoc-ref:String#scrub]. + # + # A simple example: + # + # Gem::URI.decode_www_form('foo=0&bar=1&baz') + # # => [["foo", "0"], ["bar", "1"], ["baz", ""]] + # + # The returned strings have certain conversions, + # similar to those performed in Gem::URI.decode_www_form_component: + # + # Gem::URI.decode_www_form('f%23o=%2F&b-r=%24&b+z=%40') + # # => [["f#o", "/"], ["b-r", "$"], ["b z", "@"]] + # + # The given string may contain consecutive separators: + # + # Gem::URI.decode_www_form('foo=0&&bar=1&&baz=2') + # # => [["foo", "0"], ["", ""], ["bar", "1"], ["", ""], ["baz", "2"]] + # + # A different separator may be specified: + # + # Gem::URI.decode_www_form('foo=0--bar=1--baz', separator: '--') + # # => [["foo", "0"], ["bar", "1"], ["baz", ""]] + # + def self.decode_www_form(str, enc=Encoding::UTF_8, separator: '&', use__charset_: false, isindex: false) + raise ArgumentError, "the input of #{self.name}.#{__method__} must be ASCII only string" unless str.ascii_only? + ary = [] + return ary if str.empty? + enc = Encoding.find(enc) + str.b.each_line(separator) do |string| + string.chomp!(separator) + key, sep, val = string.partition('=') + if isindex + if sep.empty? + val = key + key = +'' + end + isindex = false + end + + if use__charset_ and key == '_charset_' and e = get_encoding(val) + enc = e + use__charset_ = false + end + + key.gsub!(/\+|%\h\h/, TBLDECWWWCOMP_) + if val + val.gsub!(/\+|%\h\h/, TBLDECWWWCOMP_) + else + val = +'' + end + + ary << [key, val] + end + ary.each do |k, v| + k.force_encoding(enc) + k.scrub! + v.force_encoding(enc) + v.scrub! + end + ary + end + + private +=begin command for WEB_ENCODINGS_ + curl https://encoding.spec.whatwg.org/encodings.json| + ruby -rjson -e 'H={} + h={ + "shift_jis"=>"Windows-31J", + "euc-jp"=>"cp51932", + "iso-2022-jp"=>"cp50221", + "x-mac-cyrillic"=>"macCyrillic", + } + JSON($<.read).map{|x|x["encodings"]}.flatten.each{|x| + Encoding.find(n=h.fetch(n=x["name"].downcase,n))rescue next + x["labels"].each{|y|H[y]=n} + } + puts "{" + H.each{|k,v|puts %[ #{k.dump}=>#{v.dump},]} + puts "}" +' +=end + WEB_ENCODINGS_ = { + "unicode-1-1-utf-8"=>"utf-8", + "utf-8"=>"utf-8", + "utf8"=>"utf-8", + "866"=>"ibm866", + "cp866"=>"ibm866", + "csibm866"=>"ibm866", + "ibm866"=>"ibm866", + "csisolatin2"=>"iso-8859-2", + "iso-8859-2"=>"iso-8859-2", + "iso-ir-101"=>"iso-8859-2", + "iso8859-2"=>"iso-8859-2", + "iso88592"=>"iso-8859-2", + "iso_8859-2"=>"iso-8859-2", + "iso_8859-2:1987"=>"iso-8859-2", + "l2"=>"iso-8859-2", + "latin2"=>"iso-8859-2", + "csisolatin3"=>"iso-8859-3", + "iso-8859-3"=>"iso-8859-3", + "iso-ir-109"=>"iso-8859-3", + "iso8859-3"=>"iso-8859-3", + "iso88593"=>"iso-8859-3", + "iso_8859-3"=>"iso-8859-3", + "iso_8859-3:1988"=>"iso-8859-3", + "l3"=>"iso-8859-3", + "latin3"=>"iso-8859-3", + "csisolatin4"=>"iso-8859-4", + "iso-8859-4"=>"iso-8859-4", + "iso-ir-110"=>"iso-8859-4", + "iso8859-4"=>"iso-8859-4", + "iso88594"=>"iso-8859-4", + "iso_8859-4"=>"iso-8859-4", + "iso_8859-4:1988"=>"iso-8859-4", + "l4"=>"iso-8859-4", + "latin4"=>"iso-8859-4", + "csisolatincyrillic"=>"iso-8859-5", + "cyrillic"=>"iso-8859-5", + "iso-8859-5"=>"iso-8859-5", + "iso-ir-144"=>"iso-8859-5", + "iso8859-5"=>"iso-8859-5", + "iso88595"=>"iso-8859-5", + "iso_8859-5"=>"iso-8859-5", + "iso_8859-5:1988"=>"iso-8859-5", + "arabic"=>"iso-8859-6", + "asmo-708"=>"iso-8859-6", + "csiso88596e"=>"iso-8859-6", + "csiso88596i"=>"iso-8859-6", + "csisolatinarabic"=>"iso-8859-6", + "ecma-114"=>"iso-8859-6", + "iso-8859-6"=>"iso-8859-6", + "iso-8859-6-e"=>"iso-8859-6", + "iso-8859-6-i"=>"iso-8859-6", + "iso-ir-127"=>"iso-8859-6", + "iso8859-6"=>"iso-8859-6", + "iso88596"=>"iso-8859-6", + "iso_8859-6"=>"iso-8859-6", + "iso_8859-6:1987"=>"iso-8859-6", + "csisolatingreek"=>"iso-8859-7", + "ecma-118"=>"iso-8859-7", + "elot_928"=>"iso-8859-7", + "greek"=>"iso-8859-7", + "greek8"=>"iso-8859-7", + "iso-8859-7"=>"iso-8859-7", + "iso-ir-126"=>"iso-8859-7", + "iso8859-7"=>"iso-8859-7", + "iso88597"=>"iso-8859-7", + "iso_8859-7"=>"iso-8859-7", + "iso_8859-7:1987"=>"iso-8859-7", + "sun_eu_greek"=>"iso-8859-7", + "csiso88598e"=>"iso-8859-8", + "csisolatinhebrew"=>"iso-8859-8", + "hebrew"=>"iso-8859-8", + "iso-8859-8"=>"iso-8859-8", + "iso-8859-8-e"=>"iso-8859-8", + "iso-ir-138"=>"iso-8859-8", + "iso8859-8"=>"iso-8859-8", + "iso88598"=>"iso-8859-8", + "iso_8859-8"=>"iso-8859-8", + "iso_8859-8:1988"=>"iso-8859-8", + "visual"=>"iso-8859-8", + "csisolatin6"=>"iso-8859-10", + "iso-8859-10"=>"iso-8859-10", + "iso-ir-157"=>"iso-8859-10", + "iso8859-10"=>"iso-8859-10", + "iso885910"=>"iso-8859-10", + "l6"=>"iso-8859-10", + "latin6"=>"iso-8859-10", + "iso-8859-13"=>"iso-8859-13", + "iso8859-13"=>"iso-8859-13", + "iso885913"=>"iso-8859-13", + "iso-8859-14"=>"iso-8859-14", + "iso8859-14"=>"iso-8859-14", + "iso885914"=>"iso-8859-14", + "csisolatin9"=>"iso-8859-15", + "iso-8859-15"=>"iso-8859-15", + "iso8859-15"=>"iso-8859-15", + "iso885915"=>"iso-8859-15", + "iso_8859-15"=>"iso-8859-15", + "l9"=>"iso-8859-15", + "iso-8859-16"=>"iso-8859-16", + "cskoi8r"=>"koi8-r", + "koi"=>"koi8-r", + "koi8"=>"koi8-r", + "koi8-r"=>"koi8-r", + "koi8_r"=>"koi8-r", + "koi8-ru"=>"koi8-u", + "koi8-u"=>"koi8-u", + "dos-874"=>"windows-874", + "iso-8859-11"=>"windows-874", + "iso8859-11"=>"windows-874", + "iso885911"=>"windows-874", + "tis-620"=>"windows-874", + "windows-874"=>"windows-874", + "cp1250"=>"windows-1250", + "windows-1250"=>"windows-1250", + "x-cp1250"=>"windows-1250", + "cp1251"=>"windows-1251", + "windows-1251"=>"windows-1251", + "x-cp1251"=>"windows-1251", + "ansi_x3.4-1968"=>"windows-1252", + "ascii"=>"windows-1252", + "cp1252"=>"windows-1252", + "cp819"=>"windows-1252", + "csisolatin1"=>"windows-1252", + "ibm819"=>"windows-1252", + "iso-8859-1"=>"windows-1252", + "iso-ir-100"=>"windows-1252", + "iso8859-1"=>"windows-1252", + "iso88591"=>"windows-1252", + "iso_8859-1"=>"windows-1252", + "iso_8859-1:1987"=>"windows-1252", + "l1"=>"windows-1252", + "latin1"=>"windows-1252", + "us-ascii"=>"windows-1252", + "windows-1252"=>"windows-1252", + "x-cp1252"=>"windows-1252", + "cp1253"=>"windows-1253", + "windows-1253"=>"windows-1253", + "x-cp1253"=>"windows-1253", + "cp1254"=>"windows-1254", + "csisolatin5"=>"windows-1254", + "iso-8859-9"=>"windows-1254", + "iso-ir-148"=>"windows-1254", + "iso8859-9"=>"windows-1254", + "iso88599"=>"windows-1254", + "iso_8859-9"=>"windows-1254", + "iso_8859-9:1989"=>"windows-1254", + "l5"=>"windows-1254", + "latin5"=>"windows-1254", + "windows-1254"=>"windows-1254", + "x-cp1254"=>"windows-1254", + "cp1255"=>"windows-1255", + "windows-1255"=>"windows-1255", + "x-cp1255"=>"windows-1255", + "cp1256"=>"windows-1256", + "windows-1256"=>"windows-1256", + "x-cp1256"=>"windows-1256", + "cp1257"=>"windows-1257", + "windows-1257"=>"windows-1257", + "x-cp1257"=>"windows-1257", + "cp1258"=>"windows-1258", + "windows-1258"=>"windows-1258", + "x-cp1258"=>"windows-1258", + "x-mac-cyrillic"=>"macCyrillic", + "x-mac-ukrainian"=>"macCyrillic", + "chinese"=>"gbk", + "csgb2312"=>"gbk", + "csiso58gb231280"=>"gbk", + "gb2312"=>"gbk", + "gb_2312"=>"gbk", + "gb_2312-80"=>"gbk", + "gbk"=>"gbk", + "iso-ir-58"=>"gbk", + "x-gbk"=>"gbk", + "gb18030"=>"gb18030", + "big5"=>"big5", + "big5-hkscs"=>"big5", + "cn-big5"=>"big5", + "csbig5"=>"big5", + "x-x-big5"=>"big5", + "cseucpkdfmtjapanese"=>"cp51932", + "euc-jp"=>"cp51932", + "x-euc-jp"=>"cp51932", + "csiso2022jp"=>"cp50221", + "iso-2022-jp"=>"cp50221", + "csshiftjis"=>"Windows-31J", + "ms932"=>"Windows-31J", + "ms_kanji"=>"Windows-31J", + "shift-jis"=>"Windows-31J", + "shift_jis"=>"Windows-31J", + "sjis"=>"Windows-31J", + "windows-31j"=>"Windows-31J", + "x-sjis"=>"Windows-31J", + "cseuckr"=>"euc-kr", + "csksc56011987"=>"euc-kr", + "euc-kr"=>"euc-kr", + "iso-ir-149"=>"euc-kr", + "korean"=>"euc-kr", + "ks_c_5601-1987"=>"euc-kr", + "ks_c_5601-1989"=>"euc-kr", + "ksc5601"=>"euc-kr", + "ksc_5601"=>"euc-kr", + "windows-949"=>"euc-kr", + "utf-16be"=>"utf-16be", + "utf-16"=>"utf-16le", + "utf-16le"=>"utf-16le", + } # :nodoc: + Ractor.make_shareable(WEB_ENCODINGS_) if defined?(Ractor) + + # :nodoc: + # return encoding or nil + # http://encoding.spec.whatwg.org/#concept-encoding-get + def self.get_encoding(label) + Encoding.find(WEB_ENCODINGS_[label.to_str.strip.downcase]) rescue nil + end +end # module Gem::URI + +module Gem + + # + # Returns a \Gem::URI object derived from the given +uri+, + # which may be a \Gem::URI string or an existing \Gem::URI object: + # + # require 'rubygems/vendor/uri/lib/uri' + # # Returns a new Gem::URI. + # uri = Gem::URI('http://github.com/ruby/ruby') + # # => #<Gem::URI::HTTP http://github.com/ruby/ruby> + # # Returns the given Gem::URI. + # Gem::URI(uri) + # # => #<Gem::URI::HTTP http://github.com/ruby/ruby> + # + # You must require 'rubygems/vendor/uri/lib/uri' to use this method. + # + def URI(uri) + if uri.is_a?(Gem::URI::Generic) + uri + elsif uri = String.try_convert(uri) + Gem::URI.parse(uri) + else + raise ArgumentError, + "bad argument (expected Gem::URI object or Gem::URI string)" + end + end + module_function :URI +end diff --git a/lib/rubygems/vendor/uri/lib/uri/file.rb b/lib/rubygems/vendor/uri/lib/uri/file.rb new file mode 100644 index 0000000000..391c499716 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/file.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative 'generic' + +module Gem::URI + + # + # The "file" Gem::URI is defined by RFC8089. + # + class File < Generic + # A Default port of nil for Gem::URI::File. + DEFAULT_PORT = nil + + # + # An Array of the available components for Gem::URI::File. + # + COMPONENT = [ + :scheme, + :host, + :path + ].freeze + + # + # == Description + # + # Creates a new Gem::URI::File object from components, with syntax checking. + # + # The components accepted are +host+ and +path+. + # + # The components should be provided either as an Array, or as a Hash + # with keys formed by preceding the component names with a colon. + # + # If an Array is used, the components must be passed in the + # order <code>[host, path]</code>. + # + # A path from e.g. the File class should be escaped before + # being passed. + # + # Examples: + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri1 = Gem::URI::File.build(['host.example.com', '/path/file.zip']) + # uri1.to_s # => "file://host.example.com/path/file.zip" + # + # uri2 = Gem::URI::File.build({:host => 'host.example.com', + # :path => '/ruby/src'}) + # uri2.to_s # => "file://host.example.com/ruby/src" + # + # uri3 = Gem::URI::File.build({:path => Gem::URI::RFC2396_PARSER.escape('/path/my file.txt')}) + # uri3.to_s # => "file:///path/my%20file.txt" + # + def self.build(args) + tmp = Util::make_components_hash(self, args) + super(tmp) + end + + # Protected setter for the host component +v+. + # + # See also Gem::URI::Generic.host=. + # + def set_host(v) + v = "" if v.nil? || v == "localhost" + @host = v + end + + # do nothing + def set_port(v) + end + + # raise InvalidURIError + def check_userinfo(user) + raise Gem::URI::InvalidURIError, "cannot set userinfo for file Gem::URI" + end + + # raise InvalidURIError + def check_user(user) + raise Gem::URI::InvalidURIError, "cannot set user for file Gem::URI" + end + + # raise InvalidURIError + def check_password(user) + raise Gem::URI::InvalidURIError, "cannot set password for file Gem::URI" + end + + # do nothing + def set_userinfo(v) + end + + # do nothing + def set_user(v) + end + + # do nothing + def set_password(v) + end + end + + register_scheme 'FILE', File +end diff --git a/lib/rubygems/vendor/uri/lib/uri/ftp.rb b/lib/rubygems/vendor/uri/lib/uri/ftp.rb new file mode 100644 index 0000000000..7517813029 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/ftp.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: false +# = uri/ftp.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'generic' + +module Gem::URI + + # + # FTP Gem::URI syntax is defined by RFC1738 section 3.2. + # + # This class will be redesigned because of difference of implementations; + # the structure of its path. draft-hoffman-ftp-uri-04 is a draft but it + # is a good summary about the de facto spec. + # https://datatracker.ietf.org/doc/html/draft-hoffman-ftp-uri-04 + # + class FTP < Generic + # A Default port of 21 for Gem::URI::FTP. + DEFAULT_PORT = 21 + + # + # An Array of the available components for Gem::URI::FTP. + # + COMPONENT = [ + :scheme, + :userinfo, :host, :port, + :path, :typecode + ].freeze + + # + # Typecode is "a", "i", or "d". + # + # * "a" indicates a text file (the FTP command was ASCII) + # * "i" indicates a binary file (FTP command IMAGE) + # * "d" indicates the contents of a directory should be displayed + # + TYPECODE = ['a', 'i', 'd'].freeze + + # Typecode prefix ";type=". + TYPECODE_PREFIX = ';type='.freeze + + def self.new2(user, password, host, port, path, + typecode = nil, arg_check = true) # :nodoc: + # Do not use this method! Not tested. [Bug #7301] + # This methods remains just for compatibility, + # Keep it undocumented until the active maintainer is assigned. + typecode = nil if typecode.size == 0 + if typecode && !TYPECODE.include?(typecode) + raise ArgumentError, + "bad typecode is specified: #{typecode}" + end + + # do escape + + self.new('ftp', + [user, password], + host, port, nil, + typecode ? path + TYPECODE_PREFIX + typecode : path, + nil, nil, nil, arg_check) + end + + # + # == Description + # + # Creates a new Gem::URI::FTP object from components, with syntax checking. + # + # The components accepted are +userinfo+, +host+, +port+, +path+, and + # +typecode+. + # + # The components should be provided either as an Array, or as a Hash + # with keys formed by preceding the component names with a colon. + # + # If an Array is used, the components must be passed in the + # order <code>[userinfo, host, port, path, typecode]</code>. + # + # If the path supplied is absolute, it will be escaped in order to + # make it absolute in the Gem::URI. + # + # Examples: + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri1 = Gem::URI::FTP.build(['user:password', 'ftp.example.com', nil, + # '/path/file.zip', 'i']) + # uri1.to_s # => "ftp://user:password@ftp.example.com/%2Fpath/file.zip;type=i" + # + # uri2 = Gem::URI::FTP.build({:host => 'ftp.example.com', + # :path => 'ruby/src'}) + # uri2.to_s # => "ftp://ftp.example.com/ruby/src" + # + def self.build(args) + + # Fix the incoming path to be generic URL syntax + # FTP path -> URL path + # foo/bar /foo/bar + # /foo/bar /%2Ffoo/bar + # + if args.kind_of?(Array) + args[3] = '/' + args[3].sub(/^\//, '%2F') + else + args[:path] = '/' + args[:path].sub(/^\//, '%2F') + end + + tmp = Util::make_components_hash(self, args) + + if tmp[:typecode] + if tmp[:typecode].size == 1 + tmp[:typecode] = TYPECODE_PREFIX + tmp[:typecode] + end + tmp[:path] << tmp[:typecode] + end + + return super(tmp) + end + + # + # == Description + # + # Creates a new Gem::URI::FTP object from generic URL components with no + # syntax checking. + # + # Unlike build(), this method does not escape the path component as + # required by RFC1738; instead it is treated as per RFC2396. + # + # Arguments are +scheme+, +userinfo+, +host+, +port+, +registry+, +path+, + # +opaque+, +query+, and +fragment+, in that order. + # + def initialize(scheme, + userinfo, host, port, registry, + path, opaque, + query, + fragment, + parser = nil, + arg_check = false) + raise InvalidURIError unless path + path = path.sub(/^\//,'') + path.sub!(/^%2F/,'/') + super(scheme, userinfo, host, port, registry, path, opaque, + query, fragment, parser, arg_check) + @typecode = nil + if tmp = @path.index(TYPECODE_PREFIX) + typecode = @path[tmp + TYPECODE_PREFIX.size..-1] + @path = @path[0..tmp - 1] + + if arg_check + self.typecode = typecode + else + self.set_typecode(typecode) + end + end + end + + # typecode accessor. + # + # See Gem::URI::FTP::COMPONENT. + attr_reader :typecode + + # Validates typecode +v+, + # returns +true+ or +false+. + # + def check_typecode(v) + if TYPECODE.include?(v) + return true + else + raise InvalidComponentError, + "bad typecode(expected #{TYPECODE.join(', ')}): #{v}" + end + end + private :check_typecode + + # Private setter for the typecode +v+. + # + # See also Gem::URI::FTP.typecode=. + # + def set_typecode(v) + @typecode = v + end + protected :set_typecode + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the typecode +v+ + # (with validation). + # + # See also Gem::URI::FTP.check_typecode. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("ftp://john@ftp.example.com/my_file.img") + # #=> #<Gem::URI::FTP ftp://john@ftp.example.com/my_file.img> + # uri.typecode = "i" + # uri + # #=> #<Gem::URI::FTP ftp://john@ftp.example.com/my_file.img;type=i> + # + def typecode=(typecode) + check_typecode(typecode) + set_typecode(typecode) + typecode + end + + def merge(oth) # :nodoc: + tmp = super(oth) + if self != tmp + tmp.set_typecode(oth.typecode) + end + + return tmp + end + + # Returns the path from an FTP Gem::URI. + # + # RFC 1738 specifically states that the path for an FTP Gem::URI does not + # include the / which separates the Gem::URI path from the Gem::URI host. Example: + # + # <code>ftp://ftp.example.com/pub/ruby</code> + # + # The above Gem::URI indicates that the client should connect to + # ftp.example.com then cd to pub/ruby from the initial login directory. + # + # If you want to cd to an absolute directory, you must include an + # escaped / (%2F) in the path. Example: + # + # <code>ftp://ftp.example.com/%2Fpub/ruby</code> + # + # This method will then return "/pub/ruby". + # + def path + return @path.sub(/^\//,'').sub(/^%2F/,'/') + end + + # Private setter for the path of the Gem::URI::FTP. + def set_path(v) + super("/" + v.sub(/^\//, "%2F")) + end + protected :set_path + + # Returns a String representation of the Gem::URI::FTP. + def to_s + save_path = nil + if @typecode + save_path = @path + @path = @path + TYPECODE_PREFIX + @typecode + end + str = super + if @typecode + @path = save_path + end + + return str + end + end + + register_scheme 'FTP', FTP +end diff --git a/lib/rubygems/vendor/uri/lib/uri/generic.rb b/lib/rubygems/vendor/uri/lib/uri/generic.rb new file mode 100644 index 0000000000..d0bc77dfda --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/generic.rb @@ -0,0 +1,1592 @@ +# frozen_string_literal: true + +# = uri/generic.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'common' +autoload :IPSocket, 'socket' +autoload :IPAddr, 'ipaddr' + +module Gem::URI + + # + # Base class for all Gem::URI classes. + # Implements generic Gem::URI syntax as per RFC 2396. + # + class Generic + include Gem::URI + + # + # A Default port of nil for Gem::URI::Generic. + # + DEFAULT_PORT = nil + + # + # Returns default port. + # + def self.default_port + self::DEFAULT_PORT + end + + # + # Returns default port. + # + def default_port + self.class.default_port + end + + # + # An Array of the available components for Gem::URI::Generic. + # + COMPONENT = [ + :scheme, + :userinfo, :host, :port, :registry, + :path, :opaque, + :query, + :fragment + ].freeze + + # + # Components of the Gem::URI in the order. + # + def self.component + self::COMPONENT + end + + USE_REGISTRY = false # :nodoc: + + def self.use_registry # :nodoc: + self::USE_REGISTRY + end + + # + # == Synopsis + # + # See ::new. + # + # == Description + # + # At first, tries to create a new Gem::URI::Generic instance using + # Gem::URI::Generic::build. But, if exception Gem::URI::InvalidComponentError is raised, + # then it does Gem::URI::RFC2396_PARSER.escape all Gem::URI components and tries again. + # + def self.build2(args) + begin + return self.build(args) + rescue InvalidComponentError + if args.kind_of?(Array) + return self.build(args.collect{|x| + if x.is_a?(String) + Gem::URI::RFC2396_PARSER.escape(x) + else + x + end + }) + elsif args.kind_of?(Hash) + tmp = {} + args.each do |key, value| + tmp[key] = if value + Gem::URI::RFC2396_PARSER.escape(value) + else + value + end + end + return self.build(tmp) + end + end + end + + # + # == Synopsis + # + # See ::new. + # + # == Description + # + # Creates a new Gem::URI::Generic instance from components of Gem::URI::Generic + # with check. Components are: scheme, userinfo, host, port, registry, path, + # opaque, query, and fragment. You can provide arguments either by an Array or a Hash. + # See ::new for hash keys to use or for order of array items. + # + def self.build(args) + if args.kind_of?(Array) && + args.size == ::Gem::URI::Generic::COMPONENT.size + tmp = args.dup + elsif args.kind_of?(Hash) + tmp = ::Gem::URI::Generic::COMPONENT.collect do |c| + if args.include?(c) + args[c] + else + nil + end + end + else + component = self.component rescue ::Gem::URI::Generic::COMPONENT + raise ArgumentError, + "expected Array of or Hash of components of #{self} (#{component.join(', ')})" + end + + tmp << nil + tmp << true + return self.new(*tmp) + end + + # + # == Args + # + # +scheme+:: + # Protocol scheme, i.e. 'http','ftp','mailto' and so on. + # +userinfo+:: + # User name and password, i.e. 'sdmitry:bla'. + # +host+:: + # Server host name. + # +port+:: + # Server port. + # +registry+:: + # Registry of naming authorities. + # +path+:: + # Path on server. + # +opaque+:: + # Opaque part. + # +query+:: + # Query data. + # +fragment+:: + # Part of the Gem::URI after '#' character. + # +parser+:: + # Parser for internal use [Gem::URI::DEFAULT_PARSER by default]. + # +arg_check+:: + # Check arguments [false by default]. + # + # == Description + # + # Creates a new Gem::URI::Generic instance from ``generic'' components without check. + # + def initialize(scheme, + userinfo, host, port, registry, + path, opaque, + query, + fragment, + parser = DEFAULT_PARSER, + arg_check = false) + @scheme = nil + @user = nil + @password = nil + @host = nil + @port = nil + @path = nil + @query = nil + @opaque = nil + @fragment = nil + @parser = parser == DEFAULT_PARSER ? nil : parser + + if arg_check + self.scheme = scheme + self.hostname = host + self.port = port + self.userinfo = userinfo + self.path = path + self.query = query + self.opaque = opaque + self.fragment = fragment + else + self.set_scheme(scheme) + self.set_host(host) + self.set_port(port) + self.set_userinfo(userinfo) + self.set_path(path) + self.query = query + self.set_opaque(opaque) + self.fragment=(fragment) + end + if registry + raise InvalidURIError, + "the scheme #{@scheme} does not accept registry part: #{registry} (or bad hostname?)" + end + + @scheme&.freeze + self.set_path('') if !@path && !@opaque # (see RFC2396 Section 5.2) + self.set_port(self.default_port) if self.default_port && !@port + end + + # + # Returns the scheme component of the Gem::URI. + # + # Gem::URI("http://foo/bar/baz").scheme #=> "http" + # + attr_reader :scheme + + # Returns the host component of the Gem::URI. + # + # Gem::URI("http://foo/bar/baz").host #=> "foo" + # + # It returns nil if no host component exists. + # + # Gem::URI("mailto:foo@example.org").host #=> nil + # + # The component does not contain the port number. + # + # Gem::URI("http://foo:8080/bar/baz").host #=> "foo" + # + # Since IPv6 addresses are wrapped with brackets in URIs, + # this method returns IPv6 addresses wrapped with brackets. + # This form is not appropriate to pass to socket methods such as TCPSocket.open. + # If unwrapped host names are required, use the #hostname method. + # + # Gem::URI("http://[::1]/bar/baz").host #=> "[::1]" + # Gem::URI("http://[::1]/bar/baz").hostname #=> "::1" + # + attr_reader :host + + # Returns the port component of the Gem::URI. + # + # Gem::URI("http://foo/bar/baz").port #=> 80 + # Gem::URI("http://foo:8080/bar/baz").port #=> 8080 + # + attr_reader :port + + def registry # :nodoc: + nil + end + + # Returns the path component of the Gem::URI. + # + # Gem::URI("http://foo/bar/baz").path #=> "/bar/baz" + # + attr_reader :path + + # Returns the query component of the Gem::URI. + # + # Gem::URI("http://foo/bar/baz?search=FooBar").query #=> "search=FooBar" + # + attr_reader :query + + # Returns the opaque part of the Gem::URI. + # + # Gem::URI("mailto:foo@example.org").opaque #=> "foo@example.org" + # Gem::URI("http://foo/bar/baz").opaque #=> nil + # + # The portion of the path that does not make use of the slash '/'. + # The path typically refers to an absolute path or an opaque part. + # (See RFC2396 Section 3 and 5.2.) + # + attr_reader :opaque + + # Returns the fragment component of the Gem::URI. + # + # Gem::URI("http://foo/bar/baz?search=FooBar#ponies").fragment #=> "ponies" + # + attr_reader :fragment + + # Returns the parser to be used. + # + # Unless the +parser+ is defined, DEFAULT_PARSER is used. + # + def parser + if !defined?(@parser) || !@parser + DEFAULT_PARSER + else + @parser || DEFAULT_PARSER + end + end + + # Replaces self by other Gem::URI object. + # + def replace!(oth) + if self.class != oth.class + raise ArgumentError, "expected #{self.class} object" + end + + component.each do |c| + self.__send__("#{c}=", oth.__send__(c)) + end + end + private :replace! + + # + # Components of the Gem::URI in the order. + # + def component + self.class.component + end + + # + # Checks the scheme +v+ component against the +parser+ Regexp for :SCHEME. + # + def check_scheme(v) + if v && parser.regexp[:SCHEME] !~ v + raise InvalidComponentError, + "bad component(expected scheme component): #{v}" + end + + return true + end + private :check_scheme + + # Protected setter for the scheme component +v+. + # + # See also Gem::URI::Generic.scheme=. + # + def set_scheme(v) + @scheme = v&.downcase + end + protected :set_scheme + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the scheme component +v+ + # (with validation). + # + # See also Gem::URI::Generic.check_scheme. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com") + # uri.scheme = "https" + # uri.to_s #=> "https://my.example.com" + # + def scheme=(v) + check_scheme(v) + set_scheme(v) + v + end + + # + # Checks the +user+ and +password+. + # + # If +password+ is not provided, then +user+ is + # split, using Gem::URI::Generic.split_userinfo, to + # pull +user+ and +password. + # + # See also Gem::URI::Generic.check_user, Gem::URI::Generic.check_password. + # + def check_userinfo(user, password = nil) + if !password + user, password = split_userinfo(user) + end + check_user(user) + check_password(password, user) + + return true + end + private :check_userinfo + + # + # Checks the user +v+ component for RFC2396 compliance + # and against the +parser+ Regexp for :USERINFO. + # + # Can not have a registry or opaque component defined, + # with a user component defined. + # + def check_user(v) + if @opaque + raise InvalidURIError, + "cannot set user with opaque" + end + + return v unless v + + if parser.regexp[:USERINFO] !~ v + raise InvalidComponentError, + "bad component(expected userinfo component or user component): #{v}" + end + + return true + end + private :check_user + + # + # Checks the password +v+ component for RFC2396 compliance + # and against the +parser+ Regexp for :USERINFO. + # + # Can not have a registry or opaque component defined, + # with a user component defined. + # + def check_password(v, user = @user) + if @opaque + raise InvalidURIError, + "cannot set password with opaque" + end + return v unless v + + if !user + raise InvalidURIError, + "password component depends user component" + end + + if parser.regexp[:USERINFO] !~ v + raise InvalidComponentError, + "bad password component" + end + + return true + end + private :check_password + + # + # Sets userinfo, argument is string like 'name:pass'. + # + def userinfo=(userinfo) + if userinfo.nil? + return nil + end + check_userinfo(*userinfo) + set_userinfo(*userinfo) + # returns userinfo + end + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the +user+ component + # (with validation). + # + # See also Gem::URI::Generic.check_user. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://john:S3nsit1ve@my.example.com") + # uri.user = "sam" + # uri.to_s #=> "http://sam:V3ry_S3nsit1ve@my.example.com" + # + def user=(user) + check_user(user) + set_user(user) + # returns user + end + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the +password+ component + # (with validation). + # + # See also Gem::URI::Generic.check_password. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://john:S3nsit1ve@my.example.com") + # uri.password = "V3ry_S3nsit1ve" + # uri.to_s #=> "http://john:V3ry_S3nsit1ve@my.example.com" + # + def password=(password) + check_password(password) + set_password(password) + # returns password + end + + # Protected setter for the +user+ component, and +password+ if available + # (with validation). + # + # See also Gem::URI::Generic.userinfo=. + # + def set_userinfo(user, password = nil) + unless password + user, password = split_userinfo(user) + end + @user = user + @password = password + + [@user, @password] + end + protected :set_userinfo + + # Protected setter for the user component +v+. + # + # See also Gem::URI::Generic.user=. + # + def set_user(v) + set_userinfo(v, nil) + v + end + protected :set_user + + # Protected setter for the password component +v+. + # + # See also Gem::URI::Generic.password=. + # + def set_password(v) + @password = v + # returns v + end + protected :set_password + + # Returns the userinfo +ui+ as <code>[user, password]</code> + # if properly formatted as 'user:password'. + def split_userinfo(ui) + return nil, nil unless ui + user, password = ui.split(':', 2) + + return user, password + end + private :split_userinfo + + # Escapes 'user:password' +v+ based on RFC 1738 section 3.1. + def escape_userpass(v) + parser.escape(v, /[@:\/]/o) # RFC 1738 section 3.1 #/ + end + private :escape_userpass + + # Returns the userinfo, either as 'user' or 'user:password'. + def userinfo + if @user.nil? + nil + elsif @password.nil? + @user + else + @user + ':' + @password + end + end + + # Returns the user component (without Gem::URI decoding). + def user + @user + end + + # Returns the password component (without Gem::URI decoding). + def password + @password + end + + # Returns the authority info (array of user, password, host and + # port), if any is set. Or returns +nil+. + def authority + return @user, @password, @host, @port if @user || @password || @host || @port + end + + # Returns the user component after Gem::URI decoding. + def decoded_user + Gem::URI.decode_uri_component(@user) if @user + end + + # Returns the password component after Gem::URI decoding. + def decoded_password + Gem::URI.decode_uri_component(@password) if @password + end + + # + # Checks the host +v+ component for RFC2396 compliance + # and against the +parser+ Regexp for :HOST. + # + # Can not have a registry or opaque component defined, + # with a host component defined. + # + def check_host(v) + return v unless v + + if @opaque + raise InvalidURIError, + "cannot set host with registry or opaque" + elsif parser.regexp[:HOST] !~ v + raise InvalidComponentError, + "bad component(expected host component): #{v}" + end + + return true + end + private :check_host + + # Protected setter for the host component +v+. + # + # See also Gem::URI::Generic.host=. + # + def set_host(v) + @host = v + end + protected :set_host + + # Protected setter for the authority info (+user+, +password+, +host+ + # and +port+). If +port+ is +nil+, +default_port+ will be set. + # + protected def set_authority(user, password, host, port = nil) + @user, @password, @host, @port = user, password, host, port || self.default_port + end + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the host component +v+ + # (with validation). + # + # See also Gem::URI::Generic.check_host. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com") + # uri.host = "foo.com" + # uri.to_s #=> "http://foo.com" + # + def host=(v) + check_host(v) + set_host(v) + set_userinfo(nil) + v + end + + # Extract the host part of the Gem::URI and unwrap brackets for IPv6 addresses. + # + # This method is the same as Gem::URI::Generic#host except + # brackets for IPv6 (and future IP) addresses are removed. + # + # uri = Gem::URI("http://[::1]/bar") + # uri.hostname #=> "::1" + # uri.host #=> "[::1]" + # + def hostname + v = self.host + v&.start_with?('[') && v.end_with?(']') ? v[1..-2] : v + end + + # Sets the host part of the Gem::URI as the argument with brackets for IPv6 addresses. + # + # This method is the same as Gem::URI::Generic#host= except + # the argument can be a bare IPv6 address. + # + # uri = Gem::URI("http://foo/bar") + # uri.hostname = "::1" + # uri.to_s #=> "http://[::1]/bar" + # + # If the argument seems to be an IPv6 address, + # it is wrapped with brackets. + # + def hostname=(v) + v = "[#{v}]" if !(v&.start_with?('[') && v&.end_with?(']')) && v&.index(':') + self.host = v + end + + # + # Checks the port +v+ component for RFC2396 compliance + # and against the +parser+ Regexp for :PORT. + # + # Can not have a registry or opaque component defined, + # with a port component defined. + # + def check_port(v) + return v unless v + + if @opaque + raise InvalidURIError, + "cannot set port with registry or opaque" + elsif !v.kind_of?(Integer) && parser.regexp[:PORT] !~ v + raise InvalidComponentError, + "bad component(expected port component): #{v.inspect}" + end + + return true + end + private :check_port + + # Protected setter for the port component +v+. + # + # See also Gem::URI::Generic.port=. + # + def set_port(v) + v = v.empty? ? nil : v.to_i unless !v || v.kind_of?(Integer) + @port = v + end + protected :set_port + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the port component +v+ + # (with validation). + # + # See also Gem::URI::Generic.check_port. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com") + # uri.port = 8080 + # uri.to_s #=> "http://my.example.com:8080" + # + def port=(v) + check_port(v) + set_port(v) + set_userinfo(nil) + port + end + + def check_registry(v) # :nodoc: + raise InvalidURIError, "cannot set registry" + end + private :check_registry + + def set_registry(v) # :nodoc: + raise InvalidURIError, "cannot set registry" + end + protected :set_registry + + def registry=(v) # :nodoc: + raise InvalidURIError, "cannot set registry" + end + + # + # Checks the path +v+ component for RFC2396 compliance + # and against the +parser+ Regexp + # for :ABS_PATH and :REL_PATH. + # + # Can not have a opaque component defined, + # with a path component defined. + # + def check_path(v) + # raise if both hier and opaque are not nil, because: + # absoluteURI = scheme ":" ( hier_part | opaque_part ) + # hier_part = ( net_path | abs_path ) [ "?" query ] + if v && @opaque + raise InvalidURIError, + "path conflicts with opaque" + end + + # If scheme is ftp, path may be relative. + # See RFC 1738 section 3.2.2, and RFC 2396. + if @scheme && @scheme != "ftp" + if v && v != '' && parser.regexp[:ABS_PATH] !~ v + raise InvalidComponentError, + "bad component(expected absolute path component): #{v}" + end + else + if v && v != '' && parser.regexp[:ABS_PATH] !~ v && + parser.regexp[:REL_PATH] !~ v + raise InvalidComponentError, + "bad component(expected relative path component): #{v}" + end + end + + return true + end + private :check_path + + # Protected setter for the path component +v+. + # + # See also Gem::URI::Generic.path=. + # + def set_path(v) + @path = v + end + protected :set_path + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the path component +v+ + # (with validation). + # + # See also Gem::URI::Generic.check_path. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com/pub/files") + # uri.path = "/faq/" + # uri.to_s #=> "http://my.example.com/faq/" + # + def path=(v) + check_path(v) + set_path(v) + v + end + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the query component +v+. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com/?id=25") + # uri.query = "id=1" + # uri.to_s #=> "http://my.example.com/?id=1" + # + def query=(v) + return @query = nil unless v + raise InvalidURIError, "query conflicts with opaque" if @opaque + + x = v.to_str + v = x.dup if x.equal? v + v.encode!(Encoding::UTF_8) rescue nil + v.delete!("\t\r\n") + v.force_encoding(Encoding::ASCII_8BIT) + raise InvalidURIError, "invalid percent escape: #{$1}" if /(%\H\H)/n.match(v) + v.gsub!(/(?!%\h\h|[!$-&(-;=?-_a-~])./n.freeze){'%%%02X' % $&.ord} + v.force_encoding(Encoding::US_ASCII) + @query = v + end + + # + # Checks the opaque +v+ component for RFC2396 compliance and + # against the +parser+ Regexp for :OPAQUE. + # + # Can not have a host, port, user, or path component defined, + # with an opaque component defined. + # + def check_opaque(v) + return v unless v + + # raise if both hier and opaque are not nil, because: + # absoluteURI = scheme ":" ( hier_part | opaque_part ) + # hier_part = ( net_path | abs_path ) [ "?" query ] + if @host || @port || @user || @path # userinfo = @user + ':' + @password + raise InvalidURIError, + "cannot set opaque with host, port, userinfo or path" + elsif v && parser.regexp[:OPAQUE] !~ v + raise InvalidComponentError, + "bad component(expected opaque component): #{v}" + end + + return true + end + private :check_opaque + + # Protected setter for the opaque component +v+. + # + # See also Gem::URI::Generic.opaque=. + # + def set_opaque(v) + @opaque = v + end + protected :set_opaque + + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the opaque component +v+ + # (with validation). + # + # See also Gem::URI::Generic.check_opaque. + # + def opaque=(v) + check_opaque(v) + set_opaque(v) + v + end + + # + # Checks the fragment +v+ component against the +parser+ Regexp for :FRAGMENT. + # + # + # == Args + # + # +v+:: + # String + # + # == Description + # + # Public setter for the fragment component +v+ + # (with validation). + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com/?id=25#time=1305212049") + # uri.fragment = "time=1305212086" + # uri.to_s #=> "http://my.example.com/?id=25#time=1305212086" + # + def fragment=(v) + return @fragment = nil unless v + + x = v.to_str + v = x.dup if x.equal? v + v.encode!(Encoding::UTF_8) rescue nil + v.delete!("\t\r\n") + v.force_encoding(Encoding::ASCII_8BIT) + v.gsub!(/(?!%\h\h|[!-~])./n){'%%%02X' % $&.ord} + v.force_encoding(Encoding::US_ASCII) + @fragment = v + end + + # + # Returns true if Gem::URI is hierarchical. + # + # == Description + # + # Gem::URI has components listed in order of decreasing significance from left to right, + # see RFC3986 https://www.rfc-editor.org/rfc/rfc3986 1.2.3. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com/") + # uri.hierarchical? + # #=> true + # uri = Gem::URI.parse("mailto:joe@example.com") + # uri.hierarchical? + # #=> false + # + def hierarchical? + if @path + true + else + false + end + end + + # + # Returns true if Gem::URI has a scheme (e.g. http:// or https://) specified. + # + def absolute? + if @scheme + true + else + false + end + end + alias absolute absolute? + + # + # Returns true if Gem::URI does not have a scheme (e.g. http:// or https://) specified. + # + def relative? + !absolute? + end + + # + # Returns an Array of the path split on '/'. + # + def split_path(path) + path.split("/", -1) + end + private :split_path + + # + # Merges a base path +base+, with relative path +rel+, + # returns a modified base path. + # + def merge_path(base, rel) + + # RFC2396, Section 5.2, 5) + # RFC2396, Section 5.2, 6) + base_path = split_path(base) + rel_path = split_path(rel) + + # RFC2396, Section 5.2, 6), a) + base_path << '' if base_path.last == '..' + while i = base_path.index('..') + base_path.slice!(i - 1, 2) + end + + if (first = rel_path.first) and first.empty? + base_path.clear + rel_path.shift + end + + # RFC2396, Section 5.2, 6), c) + # RFC2396, Section 5.2, 6), d) + rel_path.push('') if rel_path.last == '.' || rel_path.last == '..' + rel_path.delete('.') + + # RFC2396, Section 5.2, 6), e) + tmp = [] + rel_path.each do |x| + if x == '..' && + !(tmp.empty? || tmp.last == '..') + tmp.pop + else + tmp << x + end + end + + add_trailer_slash = !tmp.empty? + if base_path.empty? + base_path = [''] # keep '/' for root directory + elsif add_trailer_slash + base_path.pop + end + while x = tmp.shift + if x == '..' + # RFC2396, Section 4 + # a .. or . in an absolute path has no special meaning + base_path.pop if base_path.size > 1 + else + # if x == '..' + # valid absolute (but abnormal) path "/../..." + # else + # valid absolute path + # end + base_path << x + tmp.each {|t| base_path << t} + add_trailer_slash = false + break + end + end + base_path.push('') if add_trailer_slash + + return base_path.join('/') + end + private :merge_path + + # + # == Args + # + # +oth+:: + # Gem::URI or String + # + # == Description + # + # Destructive form of #merge. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com") + # uri.merge!("/main.rbx?page=1") + # uri.to_s # => "http://my.example.com/main.rbx?page=1" + # + def merge!(oth) + t = merge(oth) + if self == t + nil + else + replace!(t) + self + end + end + + # + # == Args + # + # +oth+:: + # Gem::URI or String + # + # == Description + # + # Merges two URIs. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com") + # uri.merge("/main.rbx?page=1") + # # => "http://my.example.com/main.rbx?page=1" + # + def merge(oth) + rel = parser.__send__(:convert_to_uri, oth) + + if rel.absolute? + #raise BadURIError, "both Gem::URI are absolute" if absolute? + # hmm... should return oth for usability? + return rel + end + + unless self.absolute? + raise BadURIError, "both Gem::URI are relative" + end + + base = self.dup + + authority = rel.authority + + # RFC2396, Section 5.2, 2) + if (rel.path.nil? || rel.path.empty?) && !authority && !rel.query + base.fragment=(rel.fragment) if rel.fragment + return base + end + + base.query = nil + base.fragment=(nil) + + # RFC2396, Section 5.2, 4) + if authority + base.set_authority(*authority) + base.set_path(rel.path) + elsif base.path && rel.path + base.set_path(merge_path(base.path, rel.path)) + end + + # RFC2396, Section 5.2, 7) + base.query = rel.query if rel.query + base.fragment=(rel.fragment) if rel.fragment + + return base + end # merge + alias + merge + + # :stopdoc: + def route_from_path(src, dst) + case dst + when src + # RFC2396, Section 4.2 + return '' + when %r{(?:\A|/)\.\.?(?:/|\z)} + # dst has abnormal absolute path, + # like "/./", "/../", "/x/../", ... + return dst.dup + end + + src_path = src.scan(%r{[^/]*/}) + dst_path = dst.scan(%r{[^/]*/?}) + + # discard same parts + while !dst_path.empty? && dst_path.first == src_path.first + src_path.shift + dst_path.shift + end + + tmp = dst_path.join + + # calculate + if src_path.empty? + if tmp.empty? + return './' + elsif dst_path.first.include?(':') # (see RFC2396 Section 5) + return './' + tmp + else + return tmp + end + end + + return '../' * src_path.size + tmp + end + private :route_from_path + # :startdoc: + + # :stopdoc: + def route_from0(oth) + oth = parser.__send__(:convert_to_uri, oth) + if self.relative? + raise BadURIError, + "relative Gem::URI: #{self}" + end + if oth.relative? + raise BadURIError, + "relative Gem::URI: #{oth}" + end + + if self.scheme != oth.scheme + return self, self.dup + end + rel = Gem::URI::Generic.new(nil, # it is relative Gem::URI + self.userinfo, self.host, self.port, + nil, self.path, self.opaque, + self.query, self.fragment, parser) + + if rel.userinfo != oth.userinfo || + rel.host.to_s.downcase != oth.host.to_s.downcase || + rel.port != oth.port + + if self.userinfo.nil? && self.host.nil? + return self, self.dup + end + + rel.set_port(nil) if rel.port == oth.default_port + return rel, rel + end + rel.set_userinfo(nil) + rel.set_host(nil) + rel.set_port(nil) + + if rel.path && rel.path == oth.path + rel.set_path('') + rel.query = nil if rel.query == oth.query + return rel, rel + elsif rel.opaque && rel.opaque == oth.opaque + rel.set_opaque('') + rel.query = nil if rel.query == oth.query + return rel, rel + end + + # you can modify `rel', but cannot `oth'. + return oth, rel + end + private :route_from0 + # :startdoc: + + # + # == Args + # + # +oth+:: + # Gem::URI or String + # + # == Description + # + # Calculates relative path from oth to self. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse('http://my.example.com/main.rbx?page=1') + # uri.route_from('http://my.example.com') + # #=> #<Gem::URI::Generic /main.rbx?page=1> + # + def route_from(oth) + # you can modify `rel', but cannot `oth'. + begin + oth, rel = route_from0(oth) + rescue + raise $!.class, $!.message + end + if oth == rel + return rel + end + + rel.set_path(route_from_path(oth.path, self.path)) + if rel.path == './' && self.query + # "./?foo" -> "?foo" + rel.set_path('') + end + + return rel + end + + alias - route_from + + # + # == Args + # + # +oth+:: + # Gem::URI or String + # + # == Description + # + # Calculates relative path to oth from self. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse('http://my.example.com') + # uri.route_to('http://my.example.com/main.rbx?page=1') + # #=> #<Gem::URI::Generic /main.rbx?page=1> + # + def route_to(oth) + parser.__send__(:convert_to_uri, oth).route_from(self) + end + + # + # Returns normalized Gem::URI. + # + # require 'rubygems/vendor/uri/lib/uri' + # + # Gem::URI("HTTP://my.EXAMPLE.com").normalize + # #=> #<Gem::URI::HTTP http://my.example.com/> + # + # Normalization here means: + # + # * scheme and host are converted to lowercase, + # * an empty path component is set to "/". + # + def normalize + uri = dup + uri.normalize! + uri + end + + # + # Destructive version of #normalize. + # + def normalize! + if path&.empty? + set_path('/') + end + if scheme && scheme != scheme.downcase + set_scheme(self.scheme.downcase) + end + if host && host != host.downcase + set_host(self.host.downcase) + end + end + + # + # Constructs String from Gem::URI. + # + def to_s + str = ''.dup + if @scheme + str << @scheme + str << ':' + end + + if @opaque + str << @opaque + else + if @host || %w[file postgres].include?(@scheme) + str << '//' + end + if self.userinfo + str << self.userinfo + str << '@' + end + if @host + str << @host + end + if @port && @port != self.default_port + str << ':' + str << @port.to_s + end + if (@host || @port) && !@path.empty? && !@path.start_with?('/') + str << '/' + end + str << @path + if @query + str << '?' + str << @query + end + end + if @fragment + str << '#' + str << @fragment + end + str + end + alias to_str to_s + + # + # Compares two URIs. + # + def ==(oth) + if self.class == oth.class + self.normalize.component_ary == oth.normalize.component_ary + else + false + end + end + + # Returns the hash value. + def hash + self.component_ary.hash + end + + # Compares with _oth_ for Hash. + def eql?(oth) + self.class == oth.class && + parser == oth.parser && + self.component_ary.eql?(oth.component_ary) + end + + # Returns an Array of the components defined from the COMPONENT Array. + def component_ary + component.collect do |x| + self.__send__(x) + end + end + protected :component_ary + + # == Args + # + # +components+:: + # Multiple Symbol arguments defined in Gem::URI::HTTP. + # + # == Description + # + # Selects specified components from Gem::URI. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse('http://myuser:mypass@my.example.com/test.rbx') + # uri.select(:userinfo, :host, :path) + # # => ["myuser:mypass", "my.example.com", "/test.rbx"] + # + def select(*components) + components.collect do |c| + if component.include?(c) + self.__send__(c) + else + raise ArgumentError, + "expected of components of #{self.class} (#{self.class.component.join(', ')})" + end + end + end + + def inspect # :nodoc: + "#<#{self.class} #{self}>" + end + + # + # == Args + # + # +v+:: + # Gem::URI or String + # + # == Description + # + # Attempts to parse other Gem::URI +oth+, + # returns [parsed_oth, self]. + # + # == Usage + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("http://my.example.com") + # uri.coerce("http://foo.com") + # #=> [#<Gem::URI::HTTP http://foo.com>, #<Gem::URI::HTTP http://my.example.com>] + # + def coerce(oth) + case oth + when String + oth = parser.parse(oth) + else + super + end + + return oth, self + end + + # Returns a proxy Gem::URI. + # The proxy Gem::URI is obtained from environment variables such as http_proxy, + # ftp_proxy, no_proxy, etc. + # If there is no proper proxy, nil is returned. + # + # If the optional parameter +env+ is specified, it is used instead of ENV. + # + # Note that capitalized variables (HTTP_PROXY, FTP_PROXY, NO_PROXY, etc.) + # are examined, too. + # + # But http_proxy and HTTP_PROXY is treated specially under CGI environment. + # It's because HTTP_PROXY may be set by Proxy: header. + # So HTTP_PROXY is not used. + # http_proxy is not used too if the variable is case insensitive. + # CGI_HTTP_PROXY can be used instead. + def find_proxy(env=ENV) + raise BadURIError, "relative Gem::URI: #{self}" if self.relative? + name = self.scheme.downcase + '_proxy' + proxy_uri = nil + if name == 'http_proxy' && env.include?('REQUEST_METHOD') # CGI? + # HTTP_PROXY conflicts with *_proxy for proxy settings and + # HTTP_* for header information in CGI. + # So it should be careful to use it. + pairs = env.reject {|k, v| /\Ahttp_proxy\z/i !~ k } + case pairs.length + when 0 # no proxy setting anyway. + proxy_uri = nil + when 1 + k, _ = pairs.shift + if k == 'http_proxy' && env[k.upcase] == nil + # http_proxy is safe to use because ENV is case sensitive. + proxy_uri = env[name] + else + proxy_uri = nil + end + else # http_proxy is safe to use because ENV is case sensitive. + proxy_uri = env.to_hash[name] + end + if !proxy_uri + # Use CGI_HTTP_PROXY. cf. libwww-perl. + proxy_uri = env["CGI_#{name.upcase}"] + end + elsif name == 'http_proxy' + if RUBY_ENGINE == 'jruby' && p_addr = ENV_JAVA['http.proxyHost'] + p_port = ENV_JAVA['http.proxyPort'] + if p_user = ENV_JAVA['http.proxyUser'] + p_pass = ENV_JAVA['http.proxyPass'] + proxy_uri = "http://#{p_user}:#{p_pass}@#{p_addr}:#{p_port}" + else + proxy_uri = "http://#{p_addr}:#{p_port}" + end + else + unless proxy_uri = env[name] + if proxy_uri = env[name.upcase] + warn 'The environment variable HTTP_PROXY is discouraged. Please use http_proxy instead.', uplevel: 1 + end + end + end + else + proxy_uri = env[name] || env[name.upcase] + end + + if proxy_uri.nil? || proxy_uri.empty? + return nil + end + + if self.hostname + begin + addr = IPSocket.getaddress(self.hostname) + return nil if /\A127\.|\A::1\z/ =~ addr + rescue SocketError + end + end + + name = 'no_proxy' + if no_proxy = env[name] || env[name.upcase] + return nil unless Gem::URI::Generic.use_proxy?(self.hostname, addr, self.port, no_proxy) + end + Gem::URI.parse(proxy_uri) + end + + def self.use_proxy?(hostname, addr, port, no_proxy) # :nodoc: + hostname = hostname.downcase + dothostname = ".#{hostname}" + no_proxy.scan(/([^:,\s]+)(?::(\d+))?/) {|p_host, p_port| + if !p_port || port == p_port.to_i + if p_host.start_with?('.') + return false if hostname.end_with?(p_host.downcase) + else + return false if dothostname.end_with?(".#{p_host.downcase}") + end + if addr + begin + return false if IPAddr.new(p_host).include?(addr) + rescue IPAddr::InvalidAddressError + next + end + end + end + } + true + end + end +end diff --git a/lib/rubygems/vendor/uri/lib/uri/http.rb b/lib/rubygems/vendor/uri/lib/uri/http.rb new file mode 100644 index 0000000000..99c78358ac --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/http.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: false +# = uri/http.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'generic' + +module Gem::URI + + # + # The syntax of HTTP URIs is defined in RFC1738 section 3.3. + # + # Note that the Ruby Gem::URI library allows HTTP URLs containing usernames and + # passwords. This is not legal as per the RFC, but used to be + # supported in Internet Explorer 5 and 6, before the MS04-004 security + # update. See <URL:http://support.microsoft.com/kb/834489>. + # + class HTTP < Generic + # A Default port of 80 for Gem::URI::HTTP. + DEFAULT_PORT = 80 + + # An Array of the available components for Gem::URI::HTTP. + COMPONENT = %i[ + scheme + userinfo host port + path + query + fragment + ].freeze + + # + # == Description + # + # Creates a new Gem::URI::HTTP object from components, with syntax checking. + # + # The components accepted are userinfo, host, port, path, query, and + # fragment. + # + # The components should be provided either as an Array, or as a Hash + # with keys formed by preceding the component names with a colon. + # + # If an Array is used, the components must be passed in the + # order <code>[userinfo, host, port, path, query, fragment]</code>. + # + # Example: + # + # uri = Gem::URI::HTTP.build(host: 'www.example.com', path: '/foo/bar') + # + # uri = Gem::URI::HTTP.build([nil, "www.example.com", nil, "/path", + # "query", 'fragment']) + # + # Currently, if passed userinfo components this method generates + # invalid HTTP URIs as per RFC 1738. + # + def self.build(args) + tmp = Util.make_components_hash(self, args) + super(tmp) + end + + # Do not allow empty host names, as they are not allowed by RFC 3986. + def check_host(v) + ret = super + + if ret && v.empty? + raise InvalidComponentError, + "bad component(expected host component): #{v}" + end + + ret + end + + # + # == Description + # + # Returns the full path for an HTTP request, as required by Net::HTTP::Get. + # + # If the Gem::URI contains a query, the full path is Gem::URI#path + '?' + Gem::URI#query. + # Otherwise, the path is simply Gem::URI#path. + # + # Example: + # + # uri = Gem::URI::HTTP.build(path: '/foo/bar', query: 'test=true') + # uri.request_uri # => "/foo/bar?test=true" + # + def request_uri + return unless @path + + url = @query ? "#@path?#@query" : @path.dup + url.start_with?(?/.freeze) ? url : ?/ + url + end + + # + # == Description + # + # Returns the authority for an HTTP uri, as defined in + # https://www.rfc-editor.org/rfc/rfc3986#section-3.2. + # + # + # Example: + # + # Gem::URI::HTTP.build(host: 'www.example.com', path: '/foo/bar').authority #=> "www.example.com" + # Gem::URI::HTTP.build(host: 'www.example.com', port: 8000, path: '/foo/bar').authority #=> "www.example.com:8000" + # Gem::URI::HTTP.build(host: 'www.example.com', port: 80, path: '/foo/bar').authority #=> "www.example.com" + # + def authority + if port == default_port + host + else + "#{host}:#{port}" + end + end + + # + # == Description + # + # Returns the origin for an HTTP uri, as defined in + # https://www.rfc-editor.org/rfc/rfc6454. + # + # + # Example: + # + # Gem::URI::HTTP.build(host: 'www.example.com', path: '/foo/bar').origin #=> "http://www.example.com" + # Gem::URI::HTTP.build(host: 'www.example.com', port: 8000, path: '/foo/bar').origin #=> "http://www.example.com:8000" + # Gem::URI::HTTP.build(host: 'www.example.com', port: 80, path: '/foo/bar').origin #=> "http://www.example.com" + # Gem::URI::HTTPS.build(host: 'www.example.com', path: '/foo/bar').origin #=> "https://www.example.com" + # + def origin + "#{scheme}://#{authority}" + end + end + + register_scheme 'HTTP', HTTP +end diff --git a/lib/rubygems/vendor/uri/lib/uri/https.rb b/lib/rubygems/vendor/uri/lib/uri/https.rb new file mode 100644 index 0000000000..6e8e732e1d --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/https.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: false +# = uri/https.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'http' + +module Gem::URI + + # The default port for HTTPS URIs is 443, and the scheme is 'https:' rather + # than 'http:'. Other than that, HTTPS URIs are identical to HTTP URIs; + # see Gem::URI::HTTP. + class HTTPS < HTTP + # A Default port of 443 for Gem::URI::HTTPS + DEFAULT_PORT = 443 + end + + register_scheme 'HTTPS', HTTPS +end diff --git a/lib/rubygems/vendor/uri/lib/uri/ldap.rb b/lib/rubygems/vendor/uri/lib/uri/ldap.rb new file mode 100644 index 0000000000..1a08b5ab7e --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/ldap.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: false +# = uri/ldap.rb +# +# Author:: +# Takaaki Tateishi <ttate@jaist.ac.jp> +# Akira Yamada <akira@ruby-lang.org> +# License:: +# Gem::URI::LDAP is copyrighted free software by Takaaki Tateishi and Akira Yamada. +# You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'generic' + +module Gem::URI + + # + # LDAP Gem::URI SCHEMA (described in RFC2255). + #-- + # ldap://<host>/<dn>[?<attrs>[?<scope>[?<filter>[?<extensions>]]]] + #++ + class LDAP < Generic + + # A Default port of 389 for Gem::URI::LDAP. + DEFAULT_PORT = 389 + + # An Array of the available components for Gem::URI::LDAP. + COMPONENT = [ + :scheme, + :host, :port, + :dn, + :attributes, + :scope, + :filter, + :extensions, + ].freeze + + # Scopes available for the starting point. + # + # * SCOPE_BASE - the Base DN + # * SCOPE_ONE - one level under the Base DN, not including the base DN and + # not including any entries under this + # * SCOPE_SUB - subtrees, all entries at all levels + # + SCOPE = [ + SCOPE_ONE = 'one', + SCOPE_SUB = 'sub', + SCOPE_BASE = 'base', + ].freeze + + # + # == Description + # + # Creates a new Gem::URI::LDAP object from components, with syntax checking. + # + # The components accepted are host, port, dn, attributes, + # scope, filter, and extensions. + # + # The components should be provided either as an Array, or as a Hash + # with keys formed by preceding the component names with a colon. + # + # If an Array is used, the components must be passed in the + # order <code>[host, port, dn, attributes, scope, filter, extensions]</code>. + # + # Example: + # + # uri = Gem::URI::LDAP.build({:host => 'ldap.example.com', + # :dn => '/dc=example'}) + # + # uri = Gem::URI::LDAP.build(["ldap.example.com", nil, + # "/dc=example;dc=com", "query", nil, nil, nil]) + # + def self.build(args) + tmp = Util::make_components_hash(self, args) + + if tmp[:dn] + tmp[:path] = tmp[:dn] + end + + query = [] + [:extensions, :filter, :scope, :attributes].collect do |x| + next if !tmp[x] && query.size == 0 + query.unshift(tmp[x]) + end + + tmp[:query] = query.join('?') + + return super(tmp) + end + + # + # == Description + # + # Creates a new Gem::URI::LDAP object from generic Gem::URI components as per + # RFC 2396. No LDAP-specific syntax checking is performed. + # + # Arguments are +scheme+, +userinfo+, +host+, +port+, +registry+, +path+, + # +opaque+, +query+, and +fragment+, in that order. + # + # Example: + # + # uri = Gem::URI::LDAP.new("ldap", nil, "ldap.example.com", nil, nil, + # "/dc=example;dc=com", nil, "query", nil) + # + # See also Gem::URI::Generic.new. + # + def initialize(*arg) + super(*arg) + + if @fragment + raise InvalidURIError, 'bad LDAP URL' + end + + parse_dn + parse_query + end + + # Private method to cleanup +dn+ from using the +path+ component attribute. + def parse_dn + raise InvalidURIError, 'bad LDAP URL' unless @path + @dn = @path[1..-1] + end + private :parse_dn + + # Private method to cleanup +attributes+, +scope+, +filter+, and +extensions+ + # from using the +query+ component attribute. + def parse_query + @attributes = nil + @scope = nil + @filter = nil + @extensions = nil + + if @query + attrs, scope, filter, extensions = @query.split('?') + + @attributes = attrs if attrs && attrs.size > 0 + @scope = scope if scope && scope.size > 0 + @filter = filter if filter && filter.size > 0 + @extensions = extensions if extensions && extensions.size > 0 + end + end + private :parse_query + + # Private method to assemble +query+ from +attributes+, +scope+, +filter+, and +extensions+. + def build_path_query + @path = '/' + @dn + + query = [] + [@extensions, @filter, @scope, @attributes].each do |x| + next if !x && query.size == 0 + query.unshift(x) + end + @query = query.join('?') + end + private :build_path_query + + # Returns dn. + def dn + @dn + end + + # Private setter for dn +val+. + def set_dn(val) + @dn = val + build_path_query + @dn + end + protected :set_dn + + # Setter for dn +val+. + def dn=(val) + set_dn(val) + val + end + + # Returns attributes. + def attributes + @attributes + end + + # Private setter for attributes +val+. + def set_attributes(val) + @attributes = val + build_path_query + @attributes + end + protected :set_attributes + + # Setter for attributes +val+. + def attributes=(val) + set_attributes(val) + val + end + + # Returns scope. + def scope + @scope + end + + # Private setter for scope +val+. + def set_scope(val) + @scope = val + build_path_query + @scope + end + protected :set_scope + + # Setter for scope +val+. + def scope=(val) + set_scope(val) + val + end + + # Returns filter. + def filter + @filter + end + + # Private setter for filter +val+. + def set_filter(val) + @filter = val + build_path_query + @filter + end + protected :set_filter + + # Setter for filter +val+. + def filter=(val) + set_filter(val) + val + end + + # Returns extensions. + def extensions + @extensions + end + + # Private setter for extensions +val+. + def set_extensions(val) + @extensions = val + build_path_query + @extensions + end + protected :set_extensions + + # Setter for extensions +val+. + def extensions=(val) + set_extensions(val) + val + end + + # Checks if Gem::URI has a path. + # For Gem::URI::LDAP this will return +false+. + def hierarchical? + false + end + end + + register_scheme 'LDAP', LDAP +end diff --git a/lib/rubygems/vendor/uri/lib/uri/ldaps.rb b/lib/rubygems/vendor/uri/lib/uri/ldaps.rb new file mode 100644 index 0000000000..b7a5b50e27 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/ldaps.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: false +# = uri/ldap.rb +# +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'ldap' + +module Gem::URI + + # The default port for LDAPS URIs is 636, and the scheme is 'ldaps:' rather + # than 'ldap:'. Other than that, LDAPS URIs are identical to LDAP URIs; + # see Gem::URI::LDAP. + class LDAPS < LDAP + # A Default port of 636 for Gem::URI::LDAPS + DEFAULT_PORT = 636 + end + + register_scheme 'LDAPS', LDAPS +end diff --git a/lib/rubygems/vendor/uri/lib/uri/mailto.rb b/lib/rubygems/vendor/uri/lib/uri/mailto.rb new file mode 100644 index 0000000000..7ae544d194 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/mailto.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: false +# = uri/mailto.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'generic' + +module Gem::URI + + # + # RFC6068, the mailto URL scheme. + # + class MailTo < Generic + include RFC2396_REGEXP + + # A Default port of nil for Gem::URI::MailTo. + DEFAULT_PORT = nil + + # An Array of the available components for Gem::URI::MailTo. + COMPONENT = [ :scheme, :to, :headers ].freeze + + # :stopdoc: + # "hname" and "hvalue" are encodings of an RFC 822 header name and + # value, respectively. As with "to", all URL reserved characters must + # be encoded. + # + # "#mailbox" is as specified in RFC 822 [RFC822]. This means that it + # consists of zero or more comma-separated mail addresses, possibly + # including "phrase" and "comment" components. Note that all URL + # reserved characters in "to" must be encoded: in particular, + # parentheses, commas, and the percent sign ("%"), which commonly occur + # in the "mailbox" syntax. + # + # Within mailto URLs, the characters "?", "=", "&" are reserved. + + # ; RFC 6068 + # hfields = "?" hfield *( "&" hfield ) + # hfield = hfname "=" hfvalue + # hfname = *qchar + # hfvalue = *qchar + # qchar = unreserved / pct-encoded / some-delims + # some-delims = "!" / "$" / "'" / "(" / ")" / "*" + # / "+" / "," / ";" / ":" / "@" + # + # ; RFC3986 + # unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + # pct-encoded = "%" HEXDIG HEXDIG + HEADER_REGEXP = /\A(?<hfield>(?:%\h\h|[!$'-.0-;@-Z_a-z~])*=(?:%\h\h|[!$'-.0-;@-Z_a-z~])*)(?:&\g<hfield>)*\z/ + # practical regexp for email address + # https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + EMAIL_REGEXP = /\A[a-zA-Z0-9.!\#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/ + # :startdoc: + + # + # == Description + # + # Creates a new Gem::URI::MailTo object from components, with syntax checking. + # + # Components can be provided as an Array or Hash. If an Array is used, + # the components must be supplied as <code>[to, headers]</code>. + # + # If a Hash is used, the keys are the component names preceded by colons. + # + # The headers can be supplied as a pre-encoded string, such as + # <code>"subject=subscribe&cc=address"</code>, or as an Array of Arrays + # like <code>[['subject', 'subscribe'], ['cc', 'address']]</code>. + # + # Examples: + # + # require 'rubygems/vendor/uri/lib/uri' + # + # m1 = Gem::URI::MailTo.build(['joe@example.com', 'subject=Ruby']) + # m1.to_s # => "mailto:joe@example.com?subject=Ruby" + # + # m2 = Gem::URI::MailTo.build(['john@example.com', [['Subject', 'Ruby'], ['Cc', 'jack@example.com']]]) + # m2.to_s # => "mailto:john@example.com?Subject=Ruby&Cc=jack@example.com" + # + # m3 = Gem::URI::MailTo.build({:to => 'listman@example.com', :headers => [['subject', 'subscribe']]}) + # m3.to_s # => "mailto:listman@example.com?subject=subscribe" + # + def self.build(args) + tmp = Util.make_components_hash(self, args) + + case tmp[:to] + when Array + tmp[:opaque] = tmp[:to].join(',') + when String + tmp[:opaque] = tmp[:to].dup + else + tmp[:opaque] = '' + end + + if tmp[:headers] + query = + case tmp[:headers] + when Array + tmp[:headers].collect { |x| + if x.kind_of?(Array) + x[0] + '=' + x[1..-1].join + else + x.to_s + end + }.join('&') + when Hash + tmp[:headers].collect { |h,v| + h + '=' + v + }.join('&') + else + tmp[:headers].to_s + end + unless query.empty? + tmp[:opaque] << '?' << query + end + end + + super(tmp) + end + + # + # == Description + # + # Creates a new Gem::URI::MailTo object from generic URL components with + # no syntax checking. + # + # This method is usually called from Gem::URI::parse, which checks + # the validity of each component. + # + def initialize(*arg) + super(*arg) + + @to = nil + @headers = [] + + # The RFC3986 parser does not normally populate opaque + @opaque = "?#{@query}" if @query && !@opaque + + unless @opaque + raise InvalidComponentError, + "missing opaque part for mailto URL" + end + to, header = @opaque.split('?', 2) + # allow semicolon as a addr-spec separator + # http://support.microsoft.com/kb/820868 + unless /\A(?:[^@,;]+@[^@,;]+(?:\z|[,;]))*\z/ =~ to + raise InvalidComponentError, + "unrecognised opaque part for mailtoURL: #{@opaque}" + end + + if arg[10] # arg_check + self.to = to + self.headers = header + else + set_to(to) + set_headers(header) + end + end + + # The primary e-mail address of the URL, as a String. + attr_reader :to + + # E-mail headers set by the URL, as an Array of Arrays. + attr_reader :headers + + # Checks the to +v+ component. + def check_to(v) + return true unless v + return true if v.size == 0 + + v.split(/[,;]/).each do |addr| + # check url safety as path-rootless + if /\A(?:%\h\h|[!$&-.0-;=@-Z_a-z~])*\z/ !~ addr + raise InvalidComponentError, + "an address in 'to' is invalid as Gem::URI #{addr.dump}" + end + + # check addr-spec + # don't s/\+/ /g + addr.gsub!(/%\h\h/, Gem::URI::TBLDECWWWCOMP_) + if EMAIL_REGEXP !~ addr + raise InvalidComponentError, + "an address in 'to' is invalid as uri-escaped addr-spec #{addr.dump}" + end + end + + true + end + private :check_to + + # Private setter for to +v+. + def set_to(v) + @to = v + end + protected :set_to + + # Setter for to +v+. + def to=(v) + check_to(v) + set_to(v) + v + end + + # Checks the headers +v+ component against either + # * HEADER_REGEXP + def check_headers(v) + return true unless v + return true if v.size == 0 + if HEADER_REGEXP !~ v + raise InvalidComponentError, + "bad component(expected opaque component): #{v}" + end + + true + end + private :check_headers + + # Private setter for headers +v+. + def set_headers(v) + @headers = [] + if v + v.split('&').each do |x| + @headers << x.split(/=/, 2) + end + end + end + protected :set_headers + + # Setter for headers +v+. + def headers=(v) + check_headers(v) + set_headers(v) + v + end + + # Constructs String from Gem::URI. + def to_s + @scheme + ':' + + if @to + @to + else + '' + end + + if @headers.size > 0 + '?' + @headers.collect{|x| x.join('=')}.join('&') + else + '' + end + + if @fragment + '#' + @fragment + else + '' + end + end + + # Returns the RFC822 e-mail text equivalent of the URL, as a String. + # + # Example: + # + # require 'rubygems/vendor/uri/lib/uri' + # + # uri = Gem::URI.parse("mailto:ruby-list@ruby-lang.org?Subject=subscribe&cc=myaddr") + # uri.to_mailtext + # # => "To: ruby-list@ruby-lang.org\nSubject: subscribe\nCc: myaddr\n\n\n" + # + def to_mailtext + to = Gem::URI.decode_www_form_component(@to) + head = '' + body = '' + @headers.each do |x| + case x[0] + when 'body' + body = Gem::URI.decode_www_form_component(x[1]) + when 'to' + to << ', ' + Gem::URI.decode_www_form_component(x[1]) + else + head << Gem::URI.decode_www_form_component(x[0]).capitalize + ': ' + + Gem::URI.decode_www_form_component(x[1]) + "\n" + end + end + + "To: #{to} +#{head} +#{body} +" + end + alias to_rfc822text to_mailtext + end + + register_scheme 'MAILTO', MailTo +end diff --git a/lib/rubygems/vendor/uri/lib/uri/rfc2396_parser.rb b/lib/rubygems/vendor/uri/lib/uri/rfc2396_parser.rb new file mode 100644 index 0000000000..2bb4181649 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/rfc2396_parser.rb @@ -0,0 +1,547 @@ +# frozen_string_literal: false +#-- +# = uri/common.rb +# +# Author:: Akira Yamada <akira@ruby-lang.org> +# License:: +# You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +module Gem::URI + # + # Includes Gem::URI::REGEXP::PATTERN + # + module RFC2396_REGEXP + # + # Patterns used to parse Gem::URI's + # + module PATTERN + # :stopdoc: + + # RFC 2396 (Gem::URI Generic Syntax) + # RFC 2732 (IPv6 Literal Addresses in URL's) + # RFC 2373 (IPv6 Addressing Architecture) + + # alpha = lowalpha | upalpha + ALPHA = "a-zA-Z" + # alphanum = alpha | digit + ALNUM = "#{ALPHA}\\d" + + # hex = digit | "A" | "B" | "C" | "D" | "E" | "F" | + # "a" | "b" | "c" | "d" | "e" | "f" + HEX = "a-fA-F\\d" + # escaped = "%" hex hex + ESCAPED = "%[#{HEX}]{2}" + # mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | + # "(" | ")" + # unreserved = alphanum | mark + UNRESERVED = "\\-_.!~*'()#{ALNUM}" + # reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | + # "$" | "," + # reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | + # "$" | "," | "[" | "]" (RFC 2732) + RESERVED = ";/?:@&=+$,\\[\\]" + + # domainlabel = alphanum | alphanum *( alphanum | "-" ) alphanum + DOMLABEL = "(?:[#{ALNUM}](?:[-#{ALNUM}]*[#{ALNUM}])?)" + # toplabel = alpha | alpha *( alphanum | "-" ) alphanum + TOPLABEL = "(?:[#{ALPHA}](?:[-#{ALNUM}]*[#{ALNUM}])?)" + # hostname = *( domainlabel "." ) toplabel [ "." ] + HOSTNAME = "(?:#{DOMLABEL}\\.)*#{TOPLABEL}\\.?" + + # :startdoc: + end # PATTERN + + # :startdoc: + end # REGEXP + + # Class that parses String's into Gem::URI's. + # + # It contains a Hash set of patterns and Regexp's that match and validate. + # + class RFC2396_Parser + include RFC2396_REGEXP + + # + # == Synopsis + # + # Gem::URI::RFC2396_Parser.new([opts]) + # + # == Args + # + # The constructor accepts a hash as options for parser. + # Keys of options are pattern names of Gem::URI components + # and values of options are pattern strings. + # The constructor generates set of regexps for parsing URIs. + # + # You can use the following keys: + # + # * :ESCAPED (Gem::URI::PATTERN::ESCAPED in default) + # * :UNRESERVED (Gem::URI::PATTERN::UNRESERVED in default) + # * :DOMLABEL (Gem::URI::PATTERN::DOMLABEL in default) + # * :TOPLABEL (Gem::URI::PATTERN::TOPLABEL in default) + # * :HOSTNAME (Gem::URI::PATTERN::HOSTNAME in default) + # + # == Examples + # + # p = Gem::URI::RFC2396_Parser.new(:ESCAPED => "(?:%[a-fA-F0-9]{2}|%u[a-fA-F0-9]{4})") + # u = p.parse("http://example.jp/%uABCD") #=> #<Gem::URI::HTTP http://example.jp/%uABCD> + # Gem::URI.parse(u.to_s) #=> raises Gem::URI::InvalidURIError + # + # s = "http://example.com/ABCD" + # u1 = p.parse(s) #=> #<Gem::URI::HTTP http://example.com/ABCD> + # u2 = Gem::URI.parse(s) #=> #<Gem::URI::HTTP http://example.com/ABCD> + # u1 == u2 #=> true + # u1.eql?(u2) #=> false + # + def initialize(opts = {}) + @pattern = initialize_pattern(opts) + @pattern.each_value(&:freeze) + @pattern.freeze + + @regexp = initialize_regexp(@pattern) + @regexp.each_value(&:freeze) + @regexp.freeze + end + + # The Hash of patterns. + # + # See also #initialize_pattern. + attr_reader :pattern + + # The Hash of Regexp. + # + # See also #initialize_regexp. + attr_reader :regexp + + # Returns a split Gem::URI against +regexp[:ABS_URI]+. + def split(uri) + case uri + when '' + # null uri + + when @regexp[:ABS_URI] + scheme, opaque, userinfo, host, port, + registry, path, query, fragment = $~[1..-1] + + # Gem::URI-reference = [ absoluteURI | relativeURI ] [ "#" fragment ] + + # absoluteURI = scheme ":" ( hier_part | opaque_part ) + # hier_part = ( net_path | abs_path ) [ "?" query ] + # opaque_part = uric_no_slash *uric + + # abs_path = "/" path_segments + # net_path = "//" authority [ abs_path ] + + # authority = server | reg_name + # server = [ [ userinfo "@" ] hostport ] + + if !scheme + raise InvalidURIError, + "bad Gem::URI (absolute but no scheme): #{uri}" + end + if !opaque && (!path && (!host && !registry)) + raise InvalidURIError, + "bad Gem::URI (absolute but no path): #{uri}" + end + + when @regexp[:REL_URI] + scheme = nil + opaque = nil + + userinfo, host, port, registry, + rel_segment, abs_path, query, fragment = $~[1..-1] + if rel_segment && abs_path + path = rel_segment + abs_path + elsif rel_segment + path = rel_segment + elsif abs_path + path = abs_path + end + + # Gem::URI-reference = [ absoluteURI | relativeURI ] [ "#" fragment ] + + # relativeURI = ( net_path | abs_path | rel_path ) [ "?" query ] + + # net_path = "//" authority [ abs_path ] + # abs_path = "/" path_segments + # rel_path = rel_segment [ abs_path ] + + # authority = server | reg_name + # server = [ [ userinfo "@" ] hostport ] + + else + raise InvalidURIError, "bad Gem::URI (is not Gem::URI?): #{uri}" + end + + path = '' if !path && !opaque # (see RFC2396 Section 5.2) + ret = [ + scheme, + userinfo, host, port, # X + registry, # X + path, # Y + opaque, # Y + query, + fragment + ] + return ret + end + + # + # == Args + # + # +uri+:: + # String + # + # == Description + # + # Parses +uri+ and constructs either matching Gem::URI scheme object + # (File, FTP, HTTP, HTTPS, LDAP, LDAPS, or MailTo) or Gem::URI::Generic. + # + # == Usage + # + # Gem::URI::RFC2396_PARSER.parse("ldap://ldap.example.com/dc=example?user=john") + # #=> #<Gem::URI::LDAP ldap://ldap.example.com/dc=example?user=john> + # + def parse(uri) + Gem::URI.for(*self.split(uri), self) + end + + # + # == Args + # + # +uris+:: + # an Array of Strings + # + # == Description + # + # Attempts to parse and merge a set of URIs. + # + def join(*uris) + uris[0] = convert_to_uri(uris[0]) + uris.inject :merge + end + + # + # :call-seq: + # extract( str ) + # extract( str, schemes ) + # extract( str, schemes ) {|item| block } + # + # == Args + # + # +str+:: + # String to search + # +schemes+:: + # Patterns to apply to +str+ + # + # == Description + # + # Attempts to parse and merge a set of URIs. + # If no +block+ given, then returns the result, + # else it calls +block+ for each element in result. + # + # See also #make_regexp. + # + def extract(str, schemes = nil) + if block_given? + str.scan(make_regexp(schemes)) { yield $& } + nil + else + result = [] + str.scan(make_regexp(schemes)) { result.push $& } + result + end + end + + # Returns Regexp that is default +self.regexp[:ABS_URI_REF]+, + # unless +schemes+ is provided. Then it is a Regexp.union with +self.pattern[:X_ABS_URI]+. + def make_regexp(schemes = nil) + unless schemes + @regexp[:ABS_URI_REF] + else + /(?=(?i:#{Regexp.union(*schemes).source}):)#{@pattern[:X_ABS_URI]}/x + end + end + + # + # :call-seq: + # escape( str ) + # escape( str, unsafe ) + # + # == Args + # + # +str+:: + # String to make safe + # +unsafe+:: + # Regexp to apply. Defaults to +self.regexp[:UNSAFE]+ + # + # == Description + # + # Constructs a safe String from +str+, removing unsafe characters, + # replacing them with codes. + # + def escape(str, unsafe = @regexp[:UNSAFE]) + unless unsafe.kind_of?(Regexp) + # perhaps unsafe is String object + unsafe = Regexp.new("[#{Regexp.quote(unsafe)}]", false) + end + str.gsub(unsafe) do + us = $& + tmp = '' + us.each_byte do |uc| + tmp << sprintf('%%%02X', uc) + end + tmp + end.force_encoding(Encoding::US_ASCII) + end + + # + # :call-seq: + # unescape( str ) + # unescape( str, escaped ) + # + # == Args + # + # +str+:: + # String to remove escapes from + # +escaped+:: + # Regexp to apply. Defaults to +self.regexp[:ESCAPED]+ + # + # == Description + # + # Removes escapes from +str+. + # + def unescape(str, escaped = @regexp[:ESCAPED]) + enc = str.encoding + enc = Encoding::UTF_8 if enc == Encoding::US_ASCII + str.gsub(escaped) { [$&[1, 2]].pack('H2').force_encoding(enc) } + end + + TO_S = Kernel.instance_method(:to_s) # :nodoc: + if TO_S.respond_to?(:bind_call) + def inspect # :nodoc: + TO_S.bind_call(self) + end + else + def inspect # :nodoc: + TO_S.bind(self).call + end + end + + private + + # Constructs the default Hash of patterns. + def initialize_pattern(opts = {}) + ret = {} + ret[:ESCAPED] = escaped = (opts.delete(:ESCAPED) || PATTERN::ESCAPED) + ret[:UNRESERVED] = unreserved = opts.delete(:UNRESERVED) || PATTERN::UNRESERVED + ret[:RESERVED] = reserved = opts.delete(:RESERVED) || PATTERN::RESERVED + ret[:DOMLABEL] = opts.delete(:DOMLABEL) || PATTERN::DOMLABEL + ret[:TOPLABEL] = opts.delete(:TOPLABEL) || PATTERN::TOPLABEL + ret[:HOSTNAME] = hostname = opts.delete(:HOSTNAME) + + # RFC 2396 (Gem::URI Generic Syntax) + # RFC 2732 (IPv6 Literal Addresses in URL's) + # RFC 2373 (IPv6 Addressing Architecture) + + # uric = reserved | unreserved | escaped + ret[:URIC] = uric = "(?:[#{unreserved}#{reserved}]|#{escaped})" + # uric_no_slash = unreserved | escaped | ";" | "?" | ":" | "@" | + # "&" | "=" | "+" | "$" | "," + ret[:URIC_NO_SLASH] = uric_no_slash = "(?:[#{unreserved};?:@&=+$,]|#{escaped})" + # query = *uric + ret[:QUERY] = query = "#{uric}*" + # fragment = *uric + ret[:FRAGMENT] = fragment = "#{uric}*" + + # hostname = *( domainlabel "." ) toplabel [ "." ] + # reg-name = *( unreserved / pct-encoded / sub-delims ) # RFC3986 + unless hostname + ret[:HOSTNAME] = hostname = "(?:[a-zA-Z0-9\\-.]|%\\h\\h)+" + end + + # RFC 2373, APPENDIX B: + # IPv6address = hexpart [ ":" IPv4address ] + # IPv4address = 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT + # hexpart = hexseq | hexseq "::" [ hexseq ] | "::" [ hexseq ] + # hexseq = hex4 *( ":" hex4) + # hex4 = 1*4HEXDIG + # + # XXX: This definition has a flaw. "::" + IPv4address must be + # allowed too. Here is a replacement. + # + # IPv4address = 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT + ret[:IPV4ADDR] = ipv4addr = "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}" + # hex4 = 1*4HEXDIG + hex4 = "[#{PATTERN::HEX}]{1,4}" + # lastpart = hex4 | IPv4address + lastpart = "(?:#{hex4}|#{ipv4addr})" + # hexseq1 = *( hex4 ":" ) hex4 + hexseq1 = "(?:#{hex4}:)*#{hex4}" + # hexseq2 = *( hex4 ":" ) lastpart + hexseq2 = "(?:#{hex4}:)*#{lastpart}" + # IPv6address = hexseq2 | [ hexseq1 ] "::" [ hexseq2 ] + ret[:IPV6ADDR] = ipv6addr = "(?:#{hexseq2}|(?:#{hexseq1})?::(?:#{hexseq2})?)" + + # IPv6prefix = ( hexseq1 | [ hexseq1 ] "::" [ hexseq1 ] ) "/" 1*2DIGIT + # unused + + # ipv6reference = "[" IPv6address "]" (RFC 2732) + ret[:IPV6REF] = ipv6ref = "\\[#{ipv6addr}\\]" + + # host = hostname | IPv4address + # host = hostname | IPv4address | IPv6reference (RFC 2732) + ret[:HOST] = host = "(?:#{hostname}|#{ipv4addr}|#{ipv6ref})" + # port = *digit + ret[:PORT] = port = '\d*' + # hostport = host [ ":" port ] + ret[:HOSTPORT] = hostport = "#{host}(?::#{port})?" + + # userinfo = *( unreserved | escaped | + # ";" | ":" | "&" | "=" | "+" | "$" | "," ) + ret[:USERINFO] = userinfo = "(?:[#{unreserved};:&=+$,]|#{escaped})*" + + # pchar = unreserved | escaped | + # ":" | "@" | "&" | "=" | "+" | "$" | "," + pchar = "(?:[#{unreserved}:@&=+$,]|#{escaped})" + # param = *pchar + param = "#{pchar}*" + # segment = *pchar *( ";" param ) + segment = "#{pchar}*(?:;#{param})*" + # path_segments = segment *( "/" segment ) + ret[:PATH_SEGMENTS] = path_segments = "#{segment}(?:/#{segment})*" + + # server = [ [ userinfo "@" ] hostport ] + server = "(?:#{userinfo}@)?#{hostport}" + # reg_name = 1*( unreserved | escaped | "$" | "," | + # ";" | ":" | "@" | "&" | "=" | "+" ) + ret[:REG_NAME] = reg_name = "(?:[#{unreserved}$,;:@&=+]|#{escaped})+" + # authority = server | reg_name + authority = "(?:#{server}|#{reg_name})" + + # rel_segment = 1*( unreserved | escaped | + # ";" | "@" | "&" | "=" | "+" | "$" | "," ) + ret[:REL_SEGMENT] = rel_segment = "(?:[#{unreserved};@&=+$,]|#{escaped})+" + + # scheme = alpha *( alpha | digit | "+" | "-" | "." ) + ret[:SCHEME] = scheme = "[#{PATTERN::ALPHA}][\\-+.#{PATTERN::ALPHA}\\d]*" + + # abs_path = "/" path_segments + ret[:ABS_PATH] = abs_path = "/#{path_segments}" + # rel_path = rel_segment [ abs_path ] + ret[:REL_PATH] = rel_path = "#{rel_segment}(?:#{abs_path})?" + # net_path = "//" authority [ abs_path ] + ret[:NET_PATH] = net_path = "//#{authority}(?:#{abs_path})?" + + # hier_part = ( net_path | abs_path ) [ "?" query ] + ret[:HIER_PART] = hier_part = "(?:#{net_path}|#{abs_path})(?:\\?(?:#{query}))?" + # opaque_part = uric_no_slash *uric + ret[:OPAQUE_PART] = opaque_part = "#{uric_no_slash}#{uric}*" + + # absoluteURI = scheme ":" ( hier_part | opaque_part ) + ret[:ABS_URI] = abs_uri = "#{scheme}:(?:#{hier_part}|#{opaque_part})" + # relativeURI = ( net_path | abs_path | rel_path ) [ "?" query ] + ret[:REL_URI] = rel_uri = "(?:#{net_path}|#{abs_path}|#{rel_path})(?:\\?#{query})?" + + # Gem::URI-reference = [ absoluteURI | relativeURI ] [ "#" fragment ] + ret[:URI_REF] = "(?:#{abs_uri}|#{rel_uri})?(?:##{fragment})?" + + ret[:X_ABS_URI] = " + (#{scheme}): (?# 1: scheme) + (?: + (#{opaque_part}) (?# 2: opaque) + | + (?:(?: + //(?: + (?:(?:(#{userinfo})@)? (?# 3: userinfo) + (?:(#{host})(?::(\\d*))?))? (?# 4: host, 5: port) + | + (#{reg_name}) (?# 6: registry) + ) + | + (?!//)) (?# XXX: '//' is the mark for hostport) + (#{abs_path})? (?# 7: path) + )(?:\\?(#{query}))? (?# 8: query) + ) + (?:\\#(#{fragment}))? (?# 9: fragment) + " + + ret[:X_REL_URI] = " + (?: + (?: + // + (?: + (?:(#{userinfo})@)? (?# 1: userinfo) + (#{host})?(?::(\\d*))? (?# 2: host, 3: port) + | + (#{reg_name}) (?# 4: registry) + ) + ) + | + (#{rel_segment}) (?# 5: rel_segment) + )? + (#{abs_path})? (?# 6: abs_path) + (?:\\?(#{query}))? (?# 7: query) + (?:\\#(#{fragment}))? (?# 8: fragment) + " + + ret + end + + # Constructs the default Hash of Regexp's. + def initialize_regexp(pattern) + ret = {} + + # for Gem::URI::split + ret[:ABS_URI] = Regexp.new('\A\s*+' + pattern[:X_ABS_URI] + '\s*\z', Regexp::EXTENDED) + ret[:REL_URI] = Regexp.new('\A\s*+' + pattern[:X_REL_URI] + '\s*\z', Regexp::EXTENDED) + + # for Gem::URI::extract + ret[:URI_REF] = Regexp.new(pattern[:URI_REF]) + ret[:ABS_URI_REF] = Regexp.new(pattern[:X_ABS_URI], Regexp::EXTENDED) + ret[:REL_URI_REF] = Regexp.new(pattern[:X_REL_URI], Regexp::EXTENDED) + + # for Gem::URI::escape/unescape + ret[:ESCAPED] = Regexp.new(pattern[:ESCAPED]) + ret[:UNSAFE] = Regexp.new("[^#{pattern[:UNRESERVED]}#{pattern[:RESERVED]}]") + + # for Generic#initialize + ret[:SCHEME] = Regexp.new("\\A#{pattern[:SCHEME]}\\z") + ret[:USERINFO] = Regexp.new("\\A#{pattern[:USERINFO]}\\z") + ret[:HOST] = Regexp.new("\\A#{pattern[:HOST]}\\z") + ret[:PORT] = Regexp.new("\\A#{pattern[:PORT]}\\z") + ret[:OPAQUE] = Regexp.new("\\A#{pattern[:OPAQUE_PART]}\\z") + ret[:REGISTRY] = Regexp.new("\\A#{pattern[:REG_NAME]}\\z") + ret[:ABS_PATH] = Regexp.new("\\A#{pattern[:ABS_PATH]}\\z") + ret[:REL_PATH] = Regexp.new("\\A#{pattern[:REL_PATH]}\\z") + ret[:QUERY] = Regexp.new("\\A#{pattern[:QUERY]}\\z") + ret[:FRAGMENT] = Regexp.new("\\A#{pattern[:FRAGMENT]}\\z") + + ret + end + + # Returns +uri+ as-is if it is Gem::URI, or convert it to Gem::URI if it is + # a String. + def convert_to_uri(uri) + if uri.is_a?(Gem::URI::Generic) + uri + elsif uri = String.try_convert(uri) + parse(uri) + else + raise ArgumentError, + "bad argument (expected Gem::URI object or Gem::URI string)" + end + end + + end # class Parser + + # Backward compatibility for Gem::URI::REGEXP::PATTERN::* + RFC2396_Parser.new.pattern.each_pair do |sym, str| + unless RFC2396_REGEXP::PATTERN.const_defined?(sym, false) + RFC2396_REGEXP::PATTERN.const_set(sym, str) + end + end +end # module Gem::URI diff --git a/lib/rubygems/vendor/uri/lib/uri/rfc3986_parser.rb b/lib/rubygems/vendor/uri/lib/uri/rfc3986_parser.rb new file mode 100644 index 0000000000..3b6961abf6 --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/rfc3986_parser.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true +module Gem::URI + class RFC3986_Parser # :nodoc: + # Gem::URI defined in RFC3986 + HOST = %r[ + (?<IP-literal>\[(?: + (?<IPv6address> + (?:\h{1,4}:){6} + (?<ls32>\h{1,4}:\h{1,4} + | (?<IPv4address>(?<dec-octet>[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]|\d) + \.\g<dec-octet>\.\g<dec-octet>\.\g<dec-octet>) + ) + | ::(?:\h{1,4}:){5}\g<ls32> + | \h{1,4}?::(?:\h{1,4}:){4}\g<ls32> + | (?:(?:\h{1,4}:)?\h{1,4})?::(?:\h{1,4}:){3}\g<ls32> + | (?:(?:\h{1,4}:){,2}\h{1,4})?::(?:\h{1,4}:){2}\g<ls32> + | (?:(?:\h{1,4}:){,3}\h{1,4})?::\h{1,4}:\g<ls32> + | (?:(?:\h{1,4}:){,4}\h{1,4})?::\g<ls32> + | (?:(?:\h{1,4}:){,5}\h{1,4})?::\h{1,4} + | (?:(?:\h{1,4}:){,6}\h{1,4})?:: + ) + | (?<IPvFuture>v\h++\.[!$&-.0-9:;=A-Z_a-z~]++) + )\]) + | \g<IPv4address> + | (?<reg-name>(?:%\h\h|[!$&-.0-9;=A-Z_a-z~])*+) + ]x + + USERINFO = /(?:%\h\h|[!$&-.0-9:;=A-Z_a-z~])*+/ + + SCHEME = %r[[A-Za-z][+\-.0-9A-Za-z]*+].source + SEG = %r[(?:%\h\h|[!$&-.0-9:;=@A-Z_a-z~/])].source + SEG_NC = %r[(?:%\h\h|[!$&-.0-9;=@A-Z_a-z~])].source + FRAGMENT = %r[(?:%\h\h|[!$&-.0-9:;=@A-Z_a-z~/?])*+].source + + RFC3986_URI = %r[\A + (?<seg>#{SEG}){0} + (?<Gem::URI> + (?<scheme>#{SCHEME}): + (?<hier-part>// + (?<authority> + (?:(?<userinfo>#{USERINFO.source})@)? + (?<host>#{HOST.source.delete(" \n")}) + (?::(?<port>\d*+))? + ) + (?<path-abempty>(?:/\g<seg>*+)?) + | (?<path-absolute>/((?!/)\g<seg>++)?) + | (?<path-rootless>(?!/)\g<seg>++) + | (?<path-empty>) + ) + (?:\?(?<query>[^\#]*+))? + (?:\#(?<fragment>#{FRAGMENT}))? + )\z]x + + RFC3986_relative_ref = %r[\A + (?<seg>#{SEG}){0} + (?<relative-ref> + (?<relative-part>// + (?<authority> + (?:(?<userinfo>#{USERINFO.source})@)? + (?<host>#{HOST.source.delete(" \n")}(?<!/))? + (?::(?<port>\d*+))? + ) + (?<path-abempty>(?:/\g<seg>*+)?) + | (?<path-absolute>/\g<seg>*+) + | (?<path-noscheme>#{SEG_NC}++(?:/\g<seg>*+)?) + | (?<path-empty>) + ) + (?:\?(?<query>[^#]*+))? + (?:\#(?<fragment>#{FRAGMENT}))? + )\z]x + attr_reader :regexp + + def initialize + @regexp = default_regexp.each_value(&:freeze).freeze + end + + def split(uri) #:nodoc: + begin + uri = uri.to_str + rescue NoMethodError + raise InvalidURIError, "bad Gem::URI (is not Gem::URI?): #{uri.inspect}" + end + uri.ascii_only? or + raise InvalidURIError, "Gem::URI must be ascii only #{uri.dump}" + if m = RFC3986_URI.match(uri) + query = m["query"] + scheme = m["scheme"] + opaque = m["path-rootless"] + if opaque + opaque << "?#{query}" if query + [ scheme, + nil, # userinfo + nil, # host + nil, # port + nil, # registry + nil, # path + opaque, + nil, # query + m["fragment"] + ] + else # normal + [ scheme, + m["userinfo"], + m["host"], + m["port"], + nil, # registry + (m["path-abempty"] || + m["path-absolute"] || + m["path-empty"]), + nil, # opaque + query, + m["fragment"] + ] + end + elsif m = RFC3986_relative_ref.match(uri) + [ nil, # scheme + m["userinfo"], + m["host"], + m["port"], + nil, # registry, + (m["path-abempty"] || + m["path-absolute"] || + m["path-noscheme"] || + m["path-empty"]), + nil, # opaque + m["query"], + m["fragment"] + ] + else + raise InvalidURIError, "bad Gem::URI (is not Gem::URI?): #{uri.inspect}" + end + end + + def parse(uri) # :nodoc: + Gem::URI.for(*self.split(uri), self) + end + + def join(*uris) # :nodoc: + uris[0] = convert_to_uri(uris[0]) + uris.inject :merge + end + + # Compatibility for RFC2396 parser + def extract(str, schemes = nil, &block) # :nodoc: + warn "Gem::URI::RFC3986_PARSER.extract is obsolete. Use Gem::URI::RFC2396_PARSER.extract explicitly.", uplevel: 1 if $VERBOSE + RFC2396_PARSER.extract(str, schemes, &block) + end + + # Compatibility for RFC2396 parser + def make_regexp(schemes = nil) # :nodoc: + warn "Gem::URI::RFC3986_PARSER.make_regexp is obsolete. Use Gem::URI::RFC2396_PARSER.make_regexp explicitly.", uplevel: 1 if $VERBOSE + RFC2396_PARSER.make_regexp(schemes) + end + + # Compatibility for RFC2396 parser + def escape(str, unsafe = nil) # :nodoc: + warn "Gem::URI::RFC3986_PARSER.escape is obsolete. Use Gem::URI::RFC2396_PARSER.escape explicitly.", uplevel: 1 if $VERBOSE + unsafe ? RFC2396_PARSER.escape(str, unsafe) : RFC2396_PARSER.escape(str) + end + + # Compatibility for RFC2396 parser + def unescape(str, escaped = nil) # :nodoc: + warn "Gem::URI::RFC3986_PARSER.unescape is obsolete. Use Gem::URI::RFC2396_PARSER.unescape explicitly.", uplevel: 1 if $VERBOSE + escaped ? RFC2396_PARSER.unescape(str, escaped) : RFC2396_PARSER.unescape(str) + end + + @@to_s = Kernel.instance_method(:to_s) + if @@to_s.respond_to?(:bind_call) + def inspect + @@to_s.bind_call(self) + end + else + def inspect + @@to_s.bind(self).call + end + end + + private + + def default_regexp # :nodoc: + { + SCHEME: %r[\A#{SCHEME}\z]o, + USERINFO: %r[\A#{USERINFO}\z]o, + HOST: %r[\A#{HOST}\z]o, + ABS_PATH: %r[\A/#{SEG}*+\z]o, + REL_PATH: %r[\A(?!/)#{SEG}++\z]o, + QUERY: %r[\A(?:%\h\h|[!$&-.0-9:;=@A-Z_a-z~/?])*+\z], + FRAGMENT: %r[\A#{FRAGMENT}\z]o, + OPAQUE: %r[\A(?:[^/].*)?\z], + PORT: /\A[\x09\x0a\x0c\x0d ]*+\d*[\x09\x0a\x0c\x0d ]*\z/, + } + end + + def convert_to_uri(uri) + if uri.is_a?(Gem::URI::Generic) + uri + elsif uri = String.try_convert(uri) + parse(uri) + else + raise ArgumentError, + "bad argument (expected Gem::URI object or Gem::URI string)" + end + end + + end # class Parser +end # module Gem::URI diff --git a/lib/rubygems/vendor/uri/lib/uri/version.rb b/lib/rubygems/vendor/uri/lib/uri/version.rb new file mode 100644 index 0000000000..7ee577887b --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/version.rb @@ -0,0 +1,6 @@ +module Gem::URI + # :stopdoc: + VERSION = '1.1.1'.freeze + VERSION_CODE = VERSION.split('.').map{|s| s.rjust(2, '0')}.join.freeze + # :startdoc: +end diff --git a/lib/rubygems/vendor/uri/lib/uri/ws.rb b/lib/rubygems/vendor/uri/lib/uri/ws.rb new file mode 100644 index 0000000000..0dd2a7a1bb --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/ws.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: false +# = uri/ws.rb +# +# Author:: Matt Muller <mamuller@amazon.com> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'generic' + +module Gem::URI + + # + # The syntax of WS URIs is defined in RFC6455 section 3. + # + # Note that the Ruby Gem::URI library allows WS URLs containing usernames and + # passwords. This is not legal as per the RFC, but used to be + # supported in Internet Explorer 5 and 6, before the MS04-004 security + # update. See <URL:http://support.microsoft.com/kb/834489>. + # + class WS < Generic + # A Default port of 80 for Gem::URI::WS. + DEFAULT_PORT = 80 + + # An Array of the available components for Gem::URI::WS. + COMPONENT = %i[ + scheme + userinfo host port + path + query + ].freeze + + # + # == Description + # + # Creates a new Gem::URI::WS object from components, with syntax checking. + # + # The components accepted are userinfo, host, port, path, and query. + # + # The components should be provided either as an Array, or as a Hash + # with keys formed by preceding the component names with a colon. + # + # If an Array is used, the components must be passed in the + # order <code>[userinfo, host, port, path, query]</code>. + # + # Example: + # + # uri = Gem::URI::WS.build(host: 'www.example.com', path: '/foo/bar') + # + # uri = Gem::URI::WS.build([nil, "www.example.com", nil, "/path", "query"]) + # + # Currently, if passed userinfo components this method generates + # invalid WS URIs as per RFC 1738. + # + def self.build(args) + tmp = Util.make_components_hash(self, args) + super(tmp) + end + + # + # == Description + # + # Returns the full path for a WS Gem::URI, as required by Net::HTTP::Get. + # + # If the Gem::URI contains a query, the full path is Gem::URI#path + '?' + Gem::URI#query. + # Otherwise, the path is simply Gem::URI#path. + # + # Example: + # + # uri = Gem::URI::WS.build(path: '/foo/bar', query: 'test=true') + # uri.request_uri # => "/foo/bar?test=true" + # + def request_uri + return unless @path + + url = @query ? "#@path?#@query" : @path.dup + url.start_with?(?/.freeze) ? url : ?/ + url + end + end + + register_scheme 'WS', WS +end diff --git a/lib/rubygems/vendor/uri/lib/uri/wss.rb b/lib/rubygems/vendor/uri/lib/uri/wss.rb new file mode 100644 index 0000000000..0b91d334bb --- /dev/null +++ b/lib/rubygems/vendor/uri/lib/uri/wss.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: false +# = uri/wss.rb +# +# Author:: Matt Muller <mamuller@amazon.com> +# License:: You can redistribute it and/or modify it under the same term as Ruby. +# +# See Gem::URI for general documentation +# + +require_relative 'ws' + +module Gem::URI + + # The default port for WSS URIs is 443, and the scheme is 'wss:' rather + # than 'ws:'. Other than that, WSS URIs are identical to WS URIs; + # see Gem::URI::WS. + class WSS < WS + # A Default port of 443 for Gem::URI::WSS + DEFAULT_PORT = 443 + end + + register_scheme 'WSS', WSS +end diff --git a/lib/rubygems/vendored_net_http.rb b/lib/rubygems/vendored_net_http.rb new file mode 100644 index 0000000000..a84c52a947 --- /dev/null +++ b/lib/rubygems/vendored_net_http.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Ruby 3.3 and RubyGems 3.5 is already load Gem::Timeout from lib/rubygems/net/http.rb +# We should avoid to load it again +require_relative "vendor/net-http/lib/net/http" unless defined?(Gem::Net::HTTP) diff --git a/lib/rubygems/vendored_optparse.rb b/lib/rubygems/vendored_optparse.rb new file mode 100644 index 0000000000..a5611d32f0 --- /dev/null +++ b/lib/rubygems/vendored_optparse.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative "vendor/optparse/lib/optparse" diff --git a/lib/rubygems/vendored_pub_grub.rb b/lib/rubygems/vendored_pub_grub.rb new file mode 100644 index 0000000000..844d243ab3 --- /dev/null +++ b/lib/rubygems/vendored_pub_grub.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative "vendor/pub_grub/lib/pub_grub" diff --git a/lib/rubygems/vendored_securerandom.rb b/lib/rubygems/vendored_securerandom.rb new file mode 100644 index 0000000000..859b6d7d7a --- /dev/null +++ b/lib/rubygems/vendored_securerandom.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative "vendor/securerandom/lib/securerandom" diff --git a/lib/rubygems/vendored_timeout.rb b/lib/rubygems/vendored_timeout.rb new file mode 100644 index 0000000000..45541928e6 --- /dev/null +++ b/lib/rubygems/vendored_timeout.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Ruby 3.3 and RubyGems 3.5 is already load Gem::Timeout from lib/rubygems/timeout.rb +# We should avoid to load it again +require_relative "vendor/timeout/lib/timeout" unless defined?(Gem::Timeout) diff --git a/lib/rubygems/vendored_tsort.rb b/lib/rubygems/vendored_tsort.rb new file mode 100644 index 0000000000..c3d815650d --- /dev/null +++ b/lib/rubygems/vendored_tsort.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative "vendor/tsort/lib/tsort" diff --git a/lib/rubygems/version.rb b/lib/rubygems/version.rb new file mode 100644 index 0000000000..306733c1d7 --- /dev/null +++ b/lib/rubygems/version.rb @@ -0,0 +1,472 @@ +# frozen_string_literal: true + +#-- +# Workaround for directly loading Gem::Version in some cases +module Gem; end +#++ + +## +# The Version class processes string versions into comparable +# values. A version string should normally be a series of numbers +# separated by periods. Each part (digits separated by periods) is +# considered its own number, and these are used for sorting. So for +# instance, 3.10 sorts higher than 3.2 because ten is greater than +# two. +# +# If any part contains letters (currently only a-z are supported) then +# that version is considered prerelease. Versions with a prerelease +# part in the Nth part sort less than versions with N-1 +# parts. Prerelease parts are sorted alphabetically using the normal +# Ruby string sorting rules. If a prerelease part contains both +# letters and numbers, it will be broken into multiple parts to +# provide expected sort behavior (1.0.a10 becomes 1.0.a.10, and is +# greater than 1.0.a9). +# +# Prereleases sort between real releases (newest to oldest): +# +# 1. 1.0 +# 2. 1.0.b1 +# 3. 1.0.a.2 +# 4. 0.9 +# +# If you want to specify a version restriction that includes both prereleases +# and regular releases of 1.x or later versions: +# +# s.add_dependency 'example', '>= 1.0.0.a' +# +# == How Software Changes +# +# Libraries generally change in 3 ways: +# +# 1. The change is an implementation detail, bug fix, security fix, or +# optimization, and has no behavioral effect on the software using it. +# +# 2. The change adds new features, and software using those new features is +# not compatible with previous versions of the library, but software using +# previous versions of the library is compatible with the change. +# +# 3. The change modifies the public interface of some part of the library in +# such a way that software that uses that part of the library must be +# modified to work. +# +# == RubyGems Rational Versioning (the recommended approach) +# +# * Versions shall be represented by three non-negative integers, separated +# by periods (e.g. 3.1.4). The first integer is the "major" version +# number, the second integer is the "minor" version number, and the third +# integer is the "patch" version number. +# +# * A category 1 change (implementation detail, bug fix, or security fix) +# will increment the patch number. +# +# * A category 2 change (backwards compatible) will increment the minor +# version number and reset the patch number. +# +# * A category 3 change (incompatible) will increment the major version number +# and reset the minor and patch numbers. +# +# * Any "public" release of a gem should have a different version. +# +# == Optimistic Vs. Pessimistic Dependency Versioning +# +# Users expect to be able to specify a version constraint that gives them +# a reasonable expectation that new versions of a library will work with +# their software if the version constraint is true, and not work with their +# software if the version constraint is false. In other words, the perfect +# system will accept all compatible versions of the library and reject all +# incompatible versions. Unfortunately, there is no perfect system, as you +# cannot predict the future. You can never know whether a future version of +# a library will contain which type of change. +# +# There are two common outlooks on dependency versioning: +# +# 1. Optimistic. This does not set an upper bound on a dependency. It is +# possible that a future version of a dependency will break the software, +# and in that case, the dependency version will need to be updated and +# changes will need to be made. +# +# 2. Pessimistic. This assumes all major version changes of a dependency will +# break the software, and that patch or minor changes of a dependency will +# not break the software. If there is a major version of a dependency +# released, the dependency version must be updated in order to use it, even +# if no code changes are actually needed. +# +# In general, optimistic versioning is superior to pessimistic versioning. +# Pessimistic versioning is often wrong in both directions. Dependencies can +# release patch or minor versions that contain incompatibilities. One +# common reason is that a security fix may require a backwards-incompatible API +# change. In this case, even though pessimistic versioning was used, it +# didn't even save effort, as you still need to make code changes and adjust +# dependency versions. Similarly, for all but the smallest dependencies, just +# because the dependency made a backwards incompatible change to one interface +# doesn't mean the dependency made a backwards incompatible change to an +# interface that the software is using. It is a common problem that a +# dependency will release a new major version and the software does not require +# any changes in order to use it. In this case, being pessimistic results in +# additional work for no benefit. +# +# When a library uses pessimistic versioning of dependencies, it causes +# significant problems if that library is not diligent about updating +# dependency versions and any library is depending on that library. +# For example: +# +# * Library A is currently on release 1.2.3. +# +# * Library B is at version 2.3.4 and has a pessimistic dependency on +# library A, using ~> 1.0 (>= 1.0, < 2). +# +# * Library C is at version 3.4.5 and has an optimistic dependency on +# library A, using >= 1.0. +# +# * Library D has optimistic dependencies on both libraries B and C. +# +# * Library A releases a new major version, 2.0.0, with new features, which +# is mostly backwards compatible, but does contain some backwards +# incompatible changes. +# +# * Library B would work with A 2.0.0, but cannot use it due to pessimistic +# versioning. +# +# * Library C wants to use the new features in the major release of library +# A to implement its own new features, so it does so, bumps the +# dependency version of A to >= 2.0, and releases version 3.5.0. +# +# * Library D cannot upgrade to the new version of library C, because it +# depends on library B, which has a pessimistic dependency on library A. +# +# * Library C releases a security fix patch version 3.5.1 to fix a +# vulnerability present in all previous versions. +# +# * Library D is now in a terrible situation. It cannot upgrade to library +# C 3.5.1, as that requires library A > 2.0, because it depends on library +# B, which requires library A > 1.0, < 2, even though library B would work +# fine with library A 2.0.0. +# +# This type of situation brought on by pessimistic versioning is unfortunately +# both common and serious in practice. +# +# This is not to say that optimistic versioning never causes a problem. +# However, with optimistic versioning, if there is a problem, it can be solved +# with the addition of a single dependency. For example, continuing the +# previous example: +# +# * Library A releases a new major version, 3.0.0, which makes backwards +# incompatible changes that break library C. +# +# * Until library C releases an updated version with new changes, library +# D only needs to set a specific dependency on library A for > 2.0, < 3, +# until library C is updated to work with the new version of library A. +# +# Both optimistic versioning and pessimistic versioning have problems in +# certain cases. However, it's significantly easier to fix optimistic +# versioning problems than to fix pessimistic versioning problems. +# +# That is not to say that pessimistic versioning is never appropriate. If the +# dependency is a library that adds a single method, where any change resulting +# in a major version bump would probably break a library using it, then using +# pessimistic versioning may be warranted. Additionally, if a dependency has +# already announced or committed backwards incompatible changes that would +# break a library's use of it, then having that library use a pessimistic +# version constraint would likely be warranted. However, outside of +# specific situations, you should avoid using pessimistic versioning, as the +# costs typically exceed the benefits. + +class Gem::Version + include Comparable + + VERSION_PATTERN = '[0-9]+(?>\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?' # :nodoc: + ANCHORED_VERSION_PATTERN = /\A\s*(#{VERSION_PATTERN})?\s*\z/ # :nodoc: + RADIX_OPT = [9_500, 3_500, 260_000, 22_227, 24].freeze # :nodoc: + + ## + # A string representation of this Version. + + def version + @version + end + + alias_method :to_s, :version + + ## + # True if the +version+ string matches RubyGems' requirements. + + def self.correct?(version) + version.nil? || ANCHORED_VERSION_PATTERN.match?(version.to_s) + end + + ## + # Factory method to create a Version object. Input may be a Version + # or a String. Intended to simplify client code. + # + # ver1 = Version.create('1.3.17') # -> (Version object) + # ver2 = Version.create(ver1) # -> (ver1) + + def self.create(input) + if self === input # check yourself before you wreck yourself + input + else + new input + end + end + + @@all = {} + @@bump = {} + @@release = {} + + def self.new(version) # :nodoc: + return super unless self == Gem::Version + + @@all[version] ||= super + end + + ## + # Constructs a Version from the +version+ string. A version string is a + # series of digits or ASCII letters separated by dots. + + def initialize(version) + unless self.class.correct?(version) + raise ArgumentError, "Malformed version number string #{version}" + end + + # If version is an empty string convert it to 0 + version = 0 if version.nil? || (version.is_a?(String) && /\A\s*\Z/.match?(version)) + + @version = version.to_s + + # optimization to avoid allocation when given an integer, since we know + # it's to_s won't have any spaces or dashes + unless version.is_a?(Integer) + @version = @version.strip + @version.gsub!("-",".pre.") + end + @version = -@version + @segments = nil + @sort_key = compute_sort_key + end + + ## + # Return a new version object where the next to the last revision + # number is one greater (e.g., 5.3.1 => 5.4). + # + # Pre-release (alpha) parts, e.g, 5.3.1.b.2 => 5.4, are ignored. + + def bump + @@bump[self] ||= begin + segments = self.segments + segments.pop while segments.any? {|s| String === s } + segments.pop if segments.size > 1 + + segments[-1] = segments[-1].succ + self.class.new segments.join(".") + end + end + + ## + # A Version is only eql? to another version if it's specified to the + # same precision. Version "1.0" is not the same as version "1". + + def eql?(other) + self.class === other && @version == other.version + end + + def hash # :nodoc: + canonical_segments.hash + end + + def init_with(coder) # :nodoc: + yaml_initialize coder.tag, coder.map + end + + def inspect # :nodoc: + "#<#{self.class} #{version.inspect}>" + end + + ## + # Dump only the raw version string, not the complete object. It's a + # string for backwards (RubyGems 1.3.5 and earlier) compatibility. + + def marshal_dump + [@version] + end + + ## + # Load custom marshal format. It's a string for backwards (RubyGems + # 1.3.5 and earlier) compatibility. + + def marshal_load(array) + string = array[0] + raise TypeError, "wrong version string" unless string.is_a?(String) + + initialize string + end + + def yaml_initialize(tag, map) # :nodoc: + @version = -map["version"] + @segments = nil + @hash = nil + end + + def encode_with(coder) # :nodoc: + coder.add "version", @version + end + + ## + # A version is considered a prerelease if it contains a letter. + + def prerelease? + unless instance_variable_defined? :@prerelease + @prerelease = /[a-zA-Z]/.match?(version) + end + @prerelease + end + + def pretty_print(q) # :nodoc: + q.text "Gem::Version.new(#{version.inspect})" + end + + ## + # The release for this version (e.g. 1.2.0.a -> 1.2.0). + # Non-prerelease versions return themselves. + + def release + @@release[self] ||= if prerelease? + segments = self.segments + segments.pop while segments.any? {|s| String === s } + self.class.new segments.join(".") + else + self + end + end + + def segments # :nodoc: + _segments.dup + end + + ## + # A recommended version for use with a >= Requirement. + + def approximate_recommendation + segments = self.segments + + segments.pop while segments.any? {|s| String === s } + segments.pop while segments.size > 2 + segments.push 0 while segments.size < 2 + + recommendation = ">= #{segments.join(".")}" + recommendation += ".a" if prerelease? + recommendation + end + + ## + # Compares this version with +other+ returning -1, 0, or 1 if the + # other version is larger, the same, or smaller than this + # one. +other+ must be an instance of Gem::Version, comparing with + # other types may raise an exception. + + def <=>(other) + if Gem::Version === other + # Fast path for comparison when available. + if @sort_key && other.sort_key + return @sort_key <=> other.sort_key + end + + return 0 if @version == other.version || canonical_segments == other.canonical_segments + + lhsegments = canonical_segments + rhsegments = other.canonical_segments + + lhsize = lhsegments.size + rhsize = rhsegments.size + limit = (lhsize > rhsize ? rhsize : lhsize) + + i = 0 + + while i < limit + lhs = lhsegments[i] + rhs = rhsegments[i] + i += 1 + + next if lhs == rhs + return -1 if String === lhs && Numeric === rhs + return 1 if Numeric === lhs && String === rhs + + return lhs <=> rhs + end + + lhs = lhsegments[i] + + if lhs.nil? + rhs = rhsegments[i] + + while i < rhsize + return 1 if String === rhs + return -1 unless rhs.zero? + rhs = rhsegments[i += 1] + end + else + while i < lhsize + return -1 if String === lhs + return 1 unless lhs.zero? + lhs = lhsegments[i += 1] + end + end + + 0 + elsif String === other + return unless self.class.correct?(other) + self <=> self.class.new(other) + end + end + + # remove trailing zeros segments before first letter or at the end of the version + def canonical_segments + @canonical_segments ||= begin + # remove trailing 0 segments, using dot or letter as anchor + # may leave a trailing dot which will be ignored by partition_segments + canonical_version = @version.sub(/(?<=[a-zA-Z.])[.0]+\z/, "") + # remove 0 segments before the first letter in a prerelease version + canonical_version.sub!(/(?<=\.|\A)[0.]+(?=[a-zA-Z])/, "") if prerelease? + partition_segments(canonical_version) + end + end + + def freeze + prerelease? + _segments + canonical_segments + super + end + + protected + + attr_reader :sort_key # :nodoc: + + def compute_sort_key + return if prerelease? + + segments = canonical_segments + return if segments.size > 5 + + key = 0 + RADIX_OPT.each_with_index do |radix, i| + seg = segments.fetch(i, 0) + return nil if seg >= radix + key = key * radix + seg + end + + key + end + + def _segments + # segments is lazy so it can pick up version values that come from + # old marshaled versions, which don't go through marshal_load. + # since this version object is cached in @@all, its @segments should be frozen + @segments ||= partition_segments(@version) + end + + def partition_segments(ver) + ver.scan(/\d+|[a-z]+/i).map! do |s| + /\A\d/.match?(s) ? s.to_i : -s + end.freeze + end +end diff --git a/lib/rubygems/version_option.rb b/lib/rubygems/version_option.rb new file mode 100644 index 0000000000..7910fd3d1b --- /dev/null +++ b/lib/rubygems/version_option.rb @@ -0,0 +1,80 @@ +# 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 "../rubygems" + +## +# Mixin methods for --version and --platform Gem::Command options. + +module Gem::VersionOption + ## + # Add the --platform option to the option parser. + + def add_platform_option(task = command, *wrap) + Gem::OptionParser.accept Gem::Platform do |value| + if value == Gem::Platform::RUBY + value + else + Gem::Platform.new value + end + end + + add_option("--platform PLATFORM", Gem::Platform, + "Specify the platform of gem to #{task}", *wrap) do |value, options| + unless options[:added_platform] + Gem.platforms = [Gem::Platform::RUBY] + options[:added_platform] = true + end + + Gem.platforms << value unless Gem.platforms.include? value + end + end + + ## + # Add the --prerelease option to the option parser. + + def add_prerelease_option(*wrap) + add_option("--[no-]prerelease", + "Allow prerelease versions of a gem", *wrap) do |value, options| + options[:prerelease] = value + options[:explicit_prerelease] = true + end + end + + ## + # Add the --version option to the option parser. + + def add_version_option(task = command, *wrap) + Gem::OptionParser.accept Gem::Requirement do |value| + Gem::Requirement.new(*value.split(/\s*,\s*/)) + end + + add_option("-v", "--version VERSION", Gem::Requirement, + "Specify version of gem to #{task}", *wrap) do |value, options| + # Allow handling for multiple --version operators + if options[:version] && !options[:version].none? + options[:version].concat([value]) + else + options[:version] = value + end + + explicit_prerelease_set = !options[:explicit_prerelease].nil? + options[:explicit_prerelease] = false unless explicit_prerelease_set + + options[:prerelease] = value.prerelease? unless + options[:explicit_prerelease] + end + end + + ## + # Extract platform given on the command line + + def get_platform_from_requirements(requirements) + Gem.platforms[1].to_s if requirements.key? :added_platform + end +end diff --git a/lib/rubygems/win_platform.rb b/lib/rubygems/win_platform.rb new file mode 100644 index 0000000000..10556871b2 --- /dev/null +++ b/lib/rubygems/win_platform.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rbconfig" + +module Gem + ## + # An Array of Regexps that match windows Ruby platforms. + + WIN_PATTERNS = [ + /bccwin/i, + /djgpp/i, + /mingw/i, + /mswin/i, + /wince/i, + ].freeze + + @@win_platform = nil + + ## + # Is this a windows platform? + + def self.win_platform? + if @@win_platform.nil? + ruby_platform = RbConfig::CONFIG["host_os"] + @@win_platform = !WIN_PATTERNS.find {|r| ruby_platform =~ r }.nil? + end + + @@win_platform + end +end diff --git a/lib/rubygems/yaml_serializer.rb b/lib/rubygems/yaml_serializer.rb new file mode 100644 index 0000000000..b2547b136b --- /dev/null +++ b/lib/rubygems/yaml_serializer.rb @@ -0,0 +1,845 @@ +# frozen_string_literal: true + +unless defined?(Psych::VERSION) + module Psych + class Exception < ::RuntimeError; end + class SyntaxError < Exception; end + class DisallowedClass < Exception; end + class BadAlias < Exception; end + class AliasesNotEnabled < BadAlias; end + end +end + +module Gem + module YAMLSerializer + Scalar = Struct.new(:value, :tag, :anchor, keyword_init: true) + + Mapping = Struct.new(:pairs, :tag, :anchor, keyword_init: true) do + def initialize(pairs: [], tag: nil, anchor: nil) + super + end + end + + Sequence = Struct.new(:items, :tag, :anchor, keyword_init: true) do + def initialize(items: [], tag: nil, anchor: nil) + super + end + end + + AliasRef = Struct.new(:name, keyword_init: true) + + class Parser + MAPPING_KEY_RE = /^((?:[^#:]|:[^ ])+):(?:[ ]+(.*))?$/ + MAX_NESTING_DEPTH = 1_000 + + def initialize(source) + @lines = source.split("\n") + @anchors = {} + @depth = 0 + strip_document_prefix + end + + def parse + return nil if @lines.empty? + + root = nil + while @lines.any? + before = @lines.size + node = parse_node(-1) + @lines.shift if @lines.size == before && @lines.any? + + if root.is_a?(Mapping) && node.is_a?(Mapping) + root.pairs.concat(node.pairs) + elsif root.nil? + root = node + end + end + root + end + + private + + def strip_document_prefix + return if @lines.empty? + return unless @lines[0]&.start_with?("---") + + if @lines[0].strip == "---" + @lines.shift + else + @lines[0] = @lines[0].sub(/^---\s*/, "") + end + end + + def parse_node(base_indent) + @depth += 1 + raise_max_nesting! if @depth > MAX_NESTING_DEPTH + + skip_blank_and_comments + return nil if @lines.empty? + + line = @lines[0] + stripped = line.lstrip + indent = line.size - stripped.size + return nil if indent < base_indent + + return parse_alias_ref if stripped.start_with?("*") + + anchor = consume_anchor + + if anchor + line = @lines[0] + stripped = line.lstrip + end + + if stripped.start_with?("- ") || stripped == "-" + parse_sequence(indent, anchor) + elsif stripped.start_with?("\"") && stripped.end_with?("\"") + # We don't need to care about the following case here: + # 1. "value with comment" # ... + # 2. "key": "value" + # + # 1. must not happen because YAMLSerializer doesn't emit any + # comment. YAMLSerializer parses only YAML that is generated + # by YAMLSerializer. + # + # 2. must not happen because #parse_node isn't used non + # top-level mapping. Non top-level mapping always uses + # #parse_mapping. Top-level mapping never use the '"key": + # "value"' form because all top-level keys + # ("!ruby/object:Gem::Specification"'s keys) are known and + # #emit_specification doesn't quote anything. + parse_plain_scalar(indent, anchor) + elsif stripped.start_with?("'") && stripped.end_with?("'") + # See also the above note for double quotation. + parse_plain_scalar(indent, anchor) + elsif stripped =~ MAPPING_KEY_RE && !stripped.start_with?("!ruby/object:") + parse_mapping(indent, anchor) + elsif stripped.start_with?("!ruby/object:") + parse_tagged_node(indent, anchor) + elsif stripped.start_with?("|") + modifier = stripped[1..].to_s.strip + @lines.shift + register_anchor(anchor, Scalar.new(value: parse_block_scalar(indent, modifier))) + else + parse_plain_scalar(indent, anchor) + end + ensure + @depth -= 1 + end + + def parse_sequence(indent, anchor) + items = [] + while @lines.any? + line = @lines[0] + stripped = line.lstrip + break unless line.size - stripped.size == indent && + (stripped.start_with?("- ") || stripped == "-") + content = @lines.shift.lstrip[1..].strip + item_anchor, content = extract_item_anchor(content) + item = parse_sequence_item(content, indent) + items << register_anchor(item_anchor, item) + end + register_anchor(anchor, Sequence.new(items: items)) + end + + def parse_sequence_item(content, indent) + if content.start_with?("*") + parse_inline_alias(content) + elsif content.empty? + @lines.any? && current_indent > indent ? parse_node(indent) : nil + elsif content.start_with?("!ruby/object:") + parse_tagged_content(content.strip, indent) + elsif content.start_with?("!binary ") + parse_binary_value(content, indent) + elsif content.start_with?("-") + @lines.unshift("#{" " * (indent + 2)}#{content}") + parse_node(indent) + elsif content =~ MAPPING_KEY_RE && !content.start_with?("!ruby/object:") + @lines.unshift("#{" " * (indent + 2)}#{content}") + parse_node(indent) + elsif content.start_with?("|") + Scalar.new(value: parse_block_scalar(indent, content[1..].to_s.strip)) + else + parse_inline_scalar(content, indent) + end + end + + def parse_mapping(indent, anchor) + pairs = [] + while @lines.any? + line = @lines[0] + stripped = line.lstrip + break unless line.size - stripped.size == indent && + stripped =~ MAPPING_KEY_RE && !stripped.start_with?("!ruby/object:") + key = $1.strip + @lines.shift + val = strip_comment($2.to_s.strip) + + key = decode_binary_tag(key) if key.start_with?("!binary ") + + val_anchor, val = consume_value_anchor(val) + value = parse_mapping_value(val, indent) + value = register_anchor(val_anchor, value) if val_anchor + + pairs << [Scalar.new(value: key), value] + end + register_anchor(anchor, Mapping.new(pairs: pairs)) + end + + def parse_mapping_value(val, indent) + if val.start_with?("*") + parse_inline_alias(val) + elsif val.start_with?("!ruby/object:") + parse_tagged_content(val.strip, indent) + elsif val.start_with?("!binary ") + parse_binary_value(val, indent) + elsif val.empty? + next_stripped = nil + next_indent = nil + if @lines.any? + next_stripped = @lines[0].lstrip + next_indent = @lines[0].size - next_stripped.size + end + if next_stripped && + (next_stripped.start_with?("- ") || next_stripped == "-") && + next_indent == indent + parse_node(indent) + else + parse_node(indent + 1) + end + elsif val == "[]" + Sequence.new + elsif val == "{}" + Mapping.new + elsif val.start_with?("|") + Scalar.new(value: parse_block_scalar(indent, val[1..].to_s.strip)) + else + parse_inline_scalar(val, indent) + end + end + + def parse_tagged_node(indent, anchor) + tag = @lines.shift.strip + nested = parse_node(indent) + apply_tag(nested, tag, anchor) + end + + def parse_tagged_content(tag, indent) + nested = parse_node(indent) + apply_tag(nested, tag, nil) + end + + def apply_tag(node, tag, anchor) + if node.is_a?(Mapping) + node.tag = tag + node.anchor = anchor + node + else + Mapping.new(pairs: [[Scalar.new(value: "value"), node]], tag: tag, anchor: anchor) + end + end + + def parse_block_scalar(base_indent, modifier) + parts = [] + block_indent = nil + + while @lines.any? + line = @lines[0] + if line.strip.empty? + parts << "\n" + @lines.shift + else + line_indent = line.size - line.lstrip.size + break if line_indent <= base_indent + block_indent ||= line_indent + parts << @lines.shift[block_indent..].to_s << "\n" + end + end + + res = parts.join + res.chomp! if modifier == "-" && res.end_with?("\n") + res + end + + def parse_plain_scalar(indent, anchor) + result = coerce(@lines.shift.strip) + return register_anchor(anchor, result) if result.is_a?(Mapping) || result.is_a?(Sequence) + + while result.is_a?(String) && @lines.any? && + !@lines[0].strip.empty? && current_indent > indent + result << " " << @lines.shift.strip + end + register_anchor(anchor, Scalar.new(value: result)) + end + + def parse_inline_scalar(val, indent) + result = coerce(val) + return result if result.is_a?(Mapping) || result.is_a?(Sequence) + + while result.is_a?(String) && @lines.any? && + !@lines[0].strip.empty? && current_indent > indent + result << " " << @lines.shift.strip + end + Scalar.new(value: result) + end + + def coerce(val, depth = 0) + raise_max_nesting! if depth > MAX_NESTING_DEPTH + + val = val.sub(/^! /, "") if val.start_with?("! ") + + if val =~ /^"(.*)"$/ + $1.gsub(/\\["nrt\\]/) do |m| + case m + when '\\"' then '"' + when "\\n" then "\n" + when "\\r" then "\r" + when "\\t" then "\t" + when "\\\\" then "\\" + end + end + elsif val =~ /^'(.*)'$/ + $1.gsub(/''/, "'") + elsif val == "true" + true + elsif val == "false" + false + elsif ["~", "null"].include?(val) + nil + elsif val == "{}" + Mapping.new + elsif val =~ /^\[(.*)\]$/ + inner = $1.strip + return Sequence.new if inner.empty? + items = inner.split(/\s*,\s*/).reject(&:empty?).map {|e| Scalar.new(value: coerce(e, depth + 1)) } + Sequence.new(items: items) + elsif /\A\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.match?(val) + begin + Time.new(val) + rescue ArgumentError + # date-only format like "2024-06-15" is not supported by Time.new + if /\A(\d{4})-(\d{2})-(\d{2})\z/.match(val) + Time.utc($1.to_i, $2.to_i, $3.to_i) + else + val + end + end + elsif /^-?\d+$/.match?(val) + val.to_i + else + val + end + end + + def decode_binary_tag(str) + content = str.sub(/\A!binary\s+/, "") + content = $1 if content =~ /\A"(.*)"\z/ || content =~ /\A'(.*)'\z/ + content.unpack1("m") + end + + def parse_binary_value(val, indent) + rest = val.sub(/\A!binary\s+/, "") + if rest.start_with?("|") + content = parse_block_scalar(indent, rest[1..].to_s.strip) + Scalar.new(value: content.unpack1("m")) + else + Scalar.new(value: decode_binary_tag(val)) + end + end + + def parse_alias_ref + AliasRef.new(name: @lines.shift.lstrip[1..].strip) + end + + def parse_inline_alias(content) + AliasRef.new(name: content[1..].strip) + end + + def current_indent + line = @lines[0] + line.size - line.lstrip.size + end + + def consume_anchor + line = @lines[0] + stripped = line.lstrip + return nil unless stripped.start_with?("&") && stripped =~ /^&(\S+)\s+/ + + anchor = $1 + @lines[0] = line.sub(/&#{Regexp.escape(anchor)}\s+/, "") + anchor + end + + def extract_item_anchor(content) + return [nil, content] unless content =~ /^&(\S+)/ + + anchor = $1 + [anchor, content.sub(/^&#{Regexp.escape(anchor)}\s*/, "")] + end + + def consume_value_anchor(val) + return [nil, val] unless val =~ /^&(\S+)\s+/ + + anchor = $1 + [anchor, val.sub(/^&#{Regexp.escape(anchor)}\s+/, "")] + end + + def register_anchor(name, node) + if name + @anchors[name] = node + node.anchor = name if node.respond_to?(:anchor=) + end + node + end + + def raise_max_nesting! + message = "exceeded maximum nesting depth (#{MAX_NESTING_DEPTH})" + if defined?(Psych::VERSION) + raise Psych::SyntaxError.new(nil, 0, 0, 0, message, nil) + else + raise Psych::SyntaxError, message + end + end + + def skip_blank_and_comments + while @lines.any? + line = @lines[0] + stripped = line.lstrip + break unless stripped.empty? || stripped.start_with?("#") + @lines.shift + end + end + + def strip_comment(val) + return val unless val.include?("#") + return val if val.lstrip.start_with?("#") + + in_single = false + in_double = false + escape = false + + val.each_char.with_index do |ch, i| + if escape + escape = false + next + end + + if in_single + in_single = false if ch == "'" + elsif in_double + if ch == "\\" + escape = true + elsif ch == '"' + in_double = false + end + else + case ch + when "'" then in_single = true + when '"' then in_double = true + when "#" then return val[0...i].rstrip + end + end + end + + val + end + end + + class Builder + VALID_OPS = %w[= != > < >= <= ~>].freeze + ARRAY_FIELDS = %w[files test_files executables extra_rdoc_files].freeze + MAX_ALIAS_RESOLUTIONS = 1_000 + + def initialize(permitted_classes: [], permitted_symbols: [], aliases: true) + @permitted_classes = permitted_classes.map {|c| "!ruby/object:#{c}" } + @permitted_symbols = permitted_symbols + @aliases = aliases + @anchor_values = {} + @alias_count = 0 + end + + def build(node) + return nil if node.nil? + + result = build_node(node) + + if result.is_a?(Hash) && result[:tag] == "!ruby/object:Gem::Specification" + build_specification(result) + else + result + end + end + + private + + def build_node(node) + case node + when nil then nil + when AliasRef then resolve_alias(node) + when Scalar then store_anchor(node.anchor, node.value) + when Mapping then build_mapping(node) + when Sequence then store_anchor(node.anchor, node.items.map {|item| build_node(item) }) + else node # already a Ruby object + end + end + + def resolve_alias(node) + raise Psych::AliasesNotEnabled unless @aliases + @alias_count += 1 + if @alias_count > MAX_ALIAS_RESOLUTIONS + raise Psych::BadAlias, "exceeded maximum alias resolutions (#{MAX_ALIAS_RESOLUTIONS})" + end + unless @anchor_values.key?(node.name) + klass = defined?(Psych::AnchorNotDefined) ? Psych::AnchorNotDefined : Psych::BadAlias + raise klass, "An alias referenced an unknown anchor: #{node.name}" + end + @anchor_values.fetch(node.name) + end + + def store_anchor(name, value) + @anchor_values[name] = value if name + value + end + + def build_mapping(node) + validate_tag!(node.tag) if node.tag + + result = case node.tag + when "!ruby/object:Gem::Version" + build_version(node) + when "!ruby/object:Gem::Platform" + build_platform(node) + when "!ruby/object:Gem::Requirement", "!ruby/object:Gem::Version::Requirement" + build_requirement(node) + when "!ruby/object:Gem::Dependency" + build_dependency(node) + when nil + build_hash(node) + when "!ruby/object:Gem::Specification" + hash = build_hash(node) + hash[:tag] = node.tag + hash + else + raise ArgumentError, "undefined class/module #{node.tag.sub("!ruby/object:", "")}" + end + + store_anchor(node.anchor, result) + end + + def build_hash(node) + result = {} + node.pairs.each do |key_node, value_node| + key = key_node.is_a?(Scalar) ? key_node.value.to_s : build_node(key_node).to_s + value = build_node(value_node) + + if ARRAY_FIELDS.include?(key) + value = normalize_array_field(value) + end + + result[key] = value + end + result + end + + def build_version(node) + hash = pairs_to_hash(node) + Gem::Version.new((hash["version"] || hash["value"]).to_s) + end + + PLATFORM_FIELDS = %w[cpu os version].freeze + PLATFORM_ALLOWED_IVARS = %w[cpu os version value].freeze + + def build_platform(node) + hash = pairs_to_hash(node) + if (hash.keys & PLATFORM_FIELDS).any? + Gem::Platform.new([hash["cpu"], hash["os"], hash["version"]]) + elsif hash["value"].is_a?(Array) + # Malformed platform (e.g. sequence instead of mapping). + # Return the raw value so yaml_initialize handles it like Psych does. + hash["value"] + else + plat = Gem::Platform.allocate + hash.each do |k, v| + plat.instance_variable_set(:"@#{k}", v) if PLATFORM_ALLOWED_IVARS.include?(k) + end + plat + end + end + + def build_requirement(node) + r = Gem::Requirement.allocate + hash = pairs_to_hash(node) + reqs = hash["requirements"] || hash["value"] + + if reqs.is_a?(Array) && !reqs.empty? + safe_reqs = [] + reqs.each do |item| + if item.is_a?(Array) && item.size == 2 + op = item[0].to_s + ver = item[1] + if VALID_OPS.include?(op) + version_obj = ver.is_a?(Gem::Version) ? ver : Gem::Version.new(ver.to_s) + safe_reqs << [op, version_obj] + end + elsif item.is_a?(String) + parsed = Gem::Requirement.parse(item) + safe_reqs << parsed + end + rescue Gem::Requirement::BadRequirementError, Gem::Version::BadVersionError + # Skip malformed items silently + end + reqs = safe_reqs unless safe_reqs.empty? + end + + r.instance_variable_set(:@requirements, reqs) + r + end + + def build_dependency(node) + hash = pairs_to_hash(node) + d = Gem::Dependency.allocate + d.instance_variable_set(:@name, hash["name"]) + + d.instance_variable_set(:@requirement, hash["requirement"] || hash["version_requirements"]) + + raw_type = hash["type"] + if raw_type + name = raw_type.to_s.sub(/^:/, "") + validate_symbol!(name) + type = name.to_sym + else + type = :runtime + end + d.instance_variable_set(:@type, type) + + d.instance_variable_set(:@prerelease, ["true", true].include?(hash["prerelease"])) + d.instance_variable_set(:@version_requirements, d.instance_variable_get(:@requirement)) + d + end + + def build_specification(hash) + spec = Gem::Specification.allocate + + normalize_specification_version!(hash) + normalize_array_fields!(hash) + + spec.yaml_initialize("!ruby/object:Gem::Specification", hash) + spec + end + + def pairs_to_hash(node) + result = {} + node.pairs.each do |key_node, value_node| + key = key_node.is_a?(Scalar) ? key_node.value.to_s : build_node(key_node).to_s + result[key] = build_node(value_node) + end + result + end + + def validate_tag!(tag) + return if @permitted_classes.include?(tag) + raise_disallowed_class!(tag) + end + + def raise_disallowed_class!(tag) + if defined?(Psych::VERSION) + raise Psych::DisallowedClass.new("load", tag) + else + raise Psych::DisallowedClass, "Tried to load unspecified class: #{tag}" + end + end + + def validate_symbol!(name) + return if @permitted_symbols.empty? || @permitted_symbols.include?(name) + + label = ":#{name}" + if defined?(Psych::VERSION) + raise Psych::DisallowedClass.new("load", label) + else + raise Psych::DisallowedClass, "Tried to load unspecified class: #{label}" + end + end + + def normalize_specification_version!(hash) + val = hash["specification_version"] + return unless val && !val.is_a?(Integer) + hash["specification_version"] = val.to_i if val.is_a?(String) && /\A\d+\z/.match?(val) + end + + def normalize_array_fields!(hash) + ARRAY_FIELDS.each do |field| + hash[field] = normalize_array_field(hash[field]) if hash[field] + end + end + + def normalize_array_field(value) + if value.is_a?(Hash) + value.values.flatten.compact + elsif !value.is_a?(Array) && value + [value].flatten.compact + else + value + end + end + end + + class Emitter + def emit(obj) + "---#{emit_node(obj, 0)}" + end + + private + + def emit_node(obj, indent, quote: false) + case obj + when Gem::Specification then emit_specification(obj, indent) + when Gem::Version then emit_version(obj, indent) + when Gem::Platform then emit_platform(obj, indent) + when Gem::Requirement then emit_requirement(obj, indent) + when Gem::Dependency then emit_dependency(obj, indent) + when Hash then emit_hash(obj, indent) + when Array then emit_array(obj, indent) + when Time then emit_time(obj) + when String then emit_string(obj, indent, quote: quote) + when NilClass + "\n" + when Numeric, Symbol, TrueClass, FalseClass + " #{obj.inspect}\n" + else + " #{obj.to_s.inspect}\n" + end + end + + def emit_specification(spec, indent) + parts = [" !ruby/object:Gem::Specification\n"] + parts << "#{pad(indent)}name:#{emit_node(spec.name, indent + 2)}" + parts << "#{pad(indent)}version:#{emit_node(spec.version, indent + 2)}" + parts << "#{pad(indent)}platform: #{spec.platform}\n" + if spec.platform.to_s != spec.original_platform.to_s + parts << "#{pad(indent)}original_platform: #{spec.original_platform}\n" + end + + attributes = Gem::Specification.attribute_names.map(&:to_s).sort - %w[name version platform] + attributes.each do |name| + val = spec.instance_variable_get("@#{name}") + next if val.nil? + parts << "#{pad(indent)}#{name}:#{emit_node(val, indent + 2)}" + end + + res = parts.join + res << "\n" unless res.end_with?("\n") + res + end + + def emit_version(ver, indent) + " !ruby/object:Gem::Version\n" \ + "#{pad(indent)}version: #{emit_node(ver.version.to_s, indent + 2).lstrip}" + end + + def emit_platform(plat, indent) + " !ruby/object:Gem::Platform\n" \ + "#{pad(indent)}cpu:#{emit_node(plat.cpu, indent + 2)}" \ + "#{pad(indent)}os:#{emit_node(plat.os, indent + 2)}" \ + "#{pad(indent)}version:#{emit_node(plat.version, indent + 2)}" + end + + def emit_requirement(req, indent) + " !ruby/object:Gem::Requirement\n" \ + "#{pad(indent)}requirements:#{emit_node(req.requirements, indent + 2)}" + end + + def emit_dependency(dep, indent) + [ + " !ruby/object:Gem::Dependency\n", + "#{pad(indent)}name: #{emit_node(dep.name, indent + 2).lstrip}", + "#{pad(indent)}requirement:#{emit_node(dep.requirement, indent + 2)}", + "#{pad(indent)}type: #{emit_node(dep.type, indent + 2).lstrip}", + "#{pad(indent)}prerelease: #{emit_node(dep.prerelease?, indent + 2).lstrip}", + "#{pad(indent)}version_requirements:#{emit_node(dep.requirement, indent + 2)}", + ].join + end + + def emit_hash(hash, indent) + if hash.empty? + " {}\n" + else + parts = ["\n"] + hash.each do |k, v| + is_symbol = k.is_a?(Symbol) || (k.is_a?(String) && k.start_with?(":")) + key_str = k.is_a?(Symbol) ? k.inspect : k.to_s + parts << "#{pad(indent)}#{key_str}:#{emit_node(v, indent + 2, quote: is_symbol)}" + end + parts.join + end + end + + def emit_array(arr, indent) + if arr.empty? + " []\n" + else + parts = ["\n"] + arr.each do |v| + parts << "#{pad(indent)}-#{emit_node(v, indent + 2)}" + end + parts.join + end + end + + def emit_time(time) + " #{time.utc.strftime("%Y-%m-%d %H:%M:%S.%N Z")}\n" + end + + def emit_string(str, indent, quote: false) + if str.include?("\n") + emit_block_scalar(str, indent) + elsif needs_quoting?(str, quote) + " #{str.to_s.inspect}\n" + else + " #{str}\n" + end + end + + def emit_block_scalar(str, indent) + parts = [str.end_with?("\n") ? " |\n" : " |-\n"] + str.each_line do |line| + parts << "#{pad(indent + 2)}#{line}" + end + res = parts.join + res << "\n" unless res.end_with?("\n") + res + end + + def needs_quoting?(str, quote) + quote || str.empty? || + str =~ /^[!*&:@%$]/ || str =~ /^-?\d+(\.\d+)?$/ || str =~ /^[<>=-]/ || + str == "true" || str == "false" || str == "nil" || + str.include?(":") || str.include?("#") || str.include?("[") || str.include?("]") || + str.include?("{") || str.include?("}") || str.include?(",") + end + + def pad(indent) + " " * indent + end + end + + module_function + + def dump(obj) + Emitter.new.emit(obj) + end + + def load(str, permitted_classes: [], permitted_symbols: [], aliases: true) + raise TypeError, "no implicit conversion of nil into String" if str.nil? + return nil if str.empty? + + ast = Parser.new(str).parse + return nil if ast.nil? + + Builder.new( + permitted_classes: permitted_classes, + permitted_symbols: permitted_symbols, + aliases: aliases + ).build(ast) + end + end +end |
